From 369e8754d7eff59028b2991d8b4d0f97a7860f6d Mon Sep 17 00:00:00 2001 From: Gordon Child Date: Wed, 21 Oct 2026 23:11:52 -0500 Subject: [PATCH 01/48] Put the treatment button in the side tab bar on iPhone Duo Apps built with the iOS 27.1 SDK get a vertical tab bar down the side of the iPhone Duo's screen. The + button was still drawn at the bottom centre, over the home screen cards, leaving an empty slot in the rail. Vertical bars add side safe-area insets, and Trio's iPhone layout is otherwise portrait-only, so a side inset means the rail: put the button in its empty slot. Every other layout keeps the bottom-centre position. --- .../Sources/Modules/Home/View/HomeRootView.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Trio/Sources/Modules/Home/View/HomeRootView.swift b/Trio/Sources/Modules/Home/View/HomeRootView.swift index e84cac530..b2f5e7ee2 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView.swift @@ -422,7 +422,7 @@ extension Home { // safe-area changes (keyboard, accessories) GeometryReader { geo in treatmentButton - .position(x: geo.size.width / 2, y: geo.size.height - 52) + .position(treatmentButtonPosition(in: geo)) } .ignoresSafeArea(.all, edges: .bottom) } @@ -436,6 +436,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 modernTabSelection: Binding { Binding( get: { selectedTab }, From 76961bc070308a4609adf71aafbcaae4bfc47ad5 Mon Sep 17 00:00:00 2001 From: Lamercho <33007090+Lamercho@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:03:11 +0200 Subject: [PATCH 02/48] Scale compact Dynamic Island text instead of truncating it in a narrow island --- LiveActivity/LiveActivity.swift | 3 ++- LiveActivity/Views/LiveActivityView.swift | 11 ++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/LiveActivity/LiveActivity.swift b/LiveActivity/LiveActivity.swift index bee5e6dda..da5a99c02 100644 --- a/LiveActivity/LiveActivity.swift +++ b/LiveActivity/LiveActivity.swift @@ -49,7 +49,8 @@ struct LiveActivity: Widget { .widgetURL(URL(string: "Trio://")) .keylineTint(glucoseColor) .contentMargins(.horizontal, 0, for: .minimal) - .contentMargins(.trailing, 0, for: .compactLeading) + // compactLeading keeps the system trailing margin: with 0 the trend arrow is cut off in a narrow island + // (iOS 27 landscape). cf. https://github.com/nightscout/Trio/issues/1657 .contentMargins(.leading, 0, for: .compactTrailing) } diff --git a/LiveActivity/Views/LiveActivityView.swift b/LiveActivity/Views/LiveActivityView.swift index d33f63303..82a71e02e 100644 --- a/LiveActivity/Views/LiveActivityView.swift +++ b/LiveActivity/Views/LiveActivityView.swift @@ -264,7 +264,12 @@ struct LiveActivityCompactLeadingView: View { var glucoseColor: Color var body: some View { - LiveActivityBGAndTrendView(context: context, size: .compact, glucoseColor: glucoseColor).padding(.leading, 4) + // iOS 27 narrows the compact views (several Live Activities, landscape): scale the text down instead of + // truncating it to "…"; no extra leading padding, so the glucose value keeps more room + // cf. https://github.com/nightscout/Trio/issues/1657 + LiveActivityBGAndTrendView(context: context, size: .compact, glucoseColor: glucoseColor) + .lineLimit(1) + .minimumScaleFactor(0.5) } } @@ -273,7 +278,11 @@ struct LiveActivityCompactTrailingView: View { var glucoseColor: Color var body: some View { + // scale the delta down instead of truncating it (see LiveActivityCompactLeadingView) + // cf. https://github.com/nightscout/Trio/issues/1657 LiveActivityGlucoseDeltaLabelView(context: context, glucoseColor: glucoseColor).padding(.trailing, 4) + .lineLimit(1) + .minimumScaleFactor(0.5) } } From e614f416ba94918eeb54299d58a8988cf1b35005 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:11:03 +0100 Subject: [PATCH 03/48] Make schedule tests DST-safe --- TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift | 5 +++-- TrioTests/CoreDataTests/ScheduledBasalInferenceTests.swift | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift b/TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift index 9e75fbb53..17370423e 100644 --- a/TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift +++ b/TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift @@ -867,8 +867,9 @@ 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 - let midnight = calendar.startOfDay(for: date) - let minutesSinceMidnight = calendar.dateComponents([.minute], from: midnight, to: date).minute ?? 0 + // 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) switch type { case .basal: diff --git a/TrioTests/CoreDataTests/ScheduledBasalInferenceTests.swift b/TrioTests/CoreDataTests/ScheduledBasalInferenceTests.swift index 9955ad0e6..c9f8d3071 100644 --- a/TrioTests/CoreDataTests/ScheduledBasalInferenceTests.swift +++ b/TrioTests/CoreDataTests/ScheduledBasalInferenceTests.swift @@ -14,9 +14,9 @@ import Testing [BasalProfileEntry(start: "00:00", minutes: 0, rate: 1.0)] } - // startOfDay-anchored so all times share one calendar day, matching the sweep's boundary math + // 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.startOfDay(for: Date()).addingTimeInterval(TimeInterval(hour * 3600 + minute * 60)) + Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date())! } private func tempBasal(_ start: Date, _ end: Date) -> TimelineEvent { @@ -218,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 { From b4de74760b588923b255c965f28e5d6fea3dccfa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 25 Oct 2026 15:10:35 +0000 Subject: [PATCH 04/48] CI: Bump APP_DEV_VERSION to 1.0.2.27 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 8f64b33ed..f5ca7fa54 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.27 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 596a197a00187153fdb64ba7ed4b703adb9c49d0 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:39:54 +0200 Subject: [PATCH 05/48] Add logging contracts and scaffold --- Trio.xcodeproj/project.pbxproj | 258 ++++++++++++++++++ .../Logger/Contracts/DeviceLogExporting.swift | 12 + .../Logger/Contracts/DiagnosticArchiver.swift | 28 ++ .../Contracts/DiagnosticReportSection.swift | 7 + .../Logger/Contracts/KnownLogSubsystems.swift | 24 ++ Trio/Sources/Logger/Contracts/LogRecord.swift | 37 +++ .../Logger/Contracts/LogRetentionPolicy.swift | 13 + Trio/Sources/Logger/Contracts/LogSink.swift | 7 + .../Sources/Logger/Core/InMemoryLogSink.swift | 11 + Trio/Sources/Logger/Core/LaunchID.swift | 5 + .../Logger/Core/LegacyFileLogSink.swift | 11 + Trio/Sources/Logger/Core/TrioLogger.swift | 3 + .../Logger/DeviceLog/DeviceLogStore.swift | 3 + .../Logger/Export/DeviceLogExporter.swift | 5 + .../Logger/Export/LogShareAction.swift | 3 + Trio/Sources/Logger/Export/OSLogCapture.swift | 3 + .../Report/DiagnosticReportGenerator.swift | 3 + .../Logger/Report/DiagnosticReportView.swift | 7 + .../Logger/Report/RedactionPatterns.swift | 3 + Trio/Sources/Logger/Report/Redactor.swift | 3 + .../Report/Sections/AlertsSection.swift | 3 + .../Report/Sections/AppLogSection.swift | 3 + .../Logger/Report/Sections/BuildSection.swift | 3 + .../Sections/DeterminationsSection.swift | 3 + .../Report/Sections/DeviceInfoSection.swift | 3 + .../Report/Sections/DeviceLogSection.swift | 3 + .../Sections/DeviceManagersSection.swift | 3 + .../Report/Sections/LoopTimingSection.swift | 3 + .../Report/Sections/NightscoutSection.swift | 3 + .../Report/Sections/SettingsSection.swift | 3 + .../Logger/Report/Sections/WatchSection.swift | 3 + .../AppLog.xcdatamodel/contents | 23 ++ Trio/Sources/Logger/Store/AppLogStore.swift | 3 + .../Logger/Store/AppLogTextExporter.swift | 3 + Trio/Sources/Models/TrioSettings.swift | 11 + .../View/ConfigEditorRootView.swift | 24 -- Trio/Sources/Views/DeviceLogShareRow.swift | 7 + Trio/Sources/Views/ShareSheet.swift | 25 ++ TrioTests/Logging/AppLogStoreTests.swift | 6 + .../Logging/DeterminationsSectionTests.swift | 6 + .../Logging/DeviceLogExporterTests.swift | 6 + .../Logging/DeviceLogShareRequestTests.swift | 6 + TrioTests/Logging/DeviceLogStoreTests.swift | 6 + .../Logging/DiagnosticArchiverTests.swift | 25 ++ .../DiagnosticReportGeneratorTests.swift | 6 + .../Logging/LegacyFileLogSinkTests.swift | 6 + TrioTests/Logging/RedactorTests.swift | 6 + TrioTests/Logging/TrioLoggerTests.swift | 6 + TrioTests/Logging/WatchLogIngestTests.swift | 6 + 49 files changed, 636 insertions(+), 24 deletions(-) create mode 100644 Trio/Sources/Logger/Contracts/DeviceLogExporting.swift create mode 100644 Trio/Sources/Logger/Contracts/DiagnosticArchiver.swift create mode 100644 Trio/Sources/Logger/Contracts/DiagnosticReportSection.swift create mode 100644 Trio/Sources/Logger/Contracts/KnownLogSubsystems.swift create mode 100644 Trio/Sources/Logger/Contracts/LogRecord.swift create mode 100644 Trio/Sources/Logger/Contracts/LogRetentionPolicy.swift create mode 100644 Trio/Sources/Logger/Contracts/LogSink.swift create mode 100644 Trio/Sources/Logger/Core/InMemoryLogSink.swift create mode 100644 Trio/Sources/Logger/Core/LaunchID.swift create mode 100644 Trio/Sources/Logger/Core/LegacyFileLogSink.swift create mode 100644 Trio/Sources/Logger/Core/TrioLogger.swift create mode 100644 Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift create mode 100644 Trio/Sources/Logger/Export/DeviceLogExporter.swift create mode 100644 Trio/Sources/Logger/Export/LogShareAction.swift create mode 100644 Trio/Sources/Logger/Export/OSLogCapture.swift create mode 100644 Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift create mode 100644 Trio/Sources/Logger/Report/DiagnosticReportView.swift create mode 100644 Trio/Sources/Logger/Report/RedactionPatterns.swift create mode 100644 Trio/Sources/Logger/Report/Redactor.swift create mode 100644 Trio/Sources/Logger/Report/Sections/AlertsSection.swift create mode 100644 Trio/Sources/Logger/Report/Sections/AppLogSection.swift create mode 100644 Trio/Sources/Logger/Report/Sections/BuildSection.swift create mode 100644 Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift create mode 100644 Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift create mode 100644 Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift create mode 100644 Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift create mode 100644 Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift create mode 100644 Trio/Sources/Logger/Report/Sections/NightscoutSection.swift create mode 100644 Trio/Sources/Logger/Report/Sections/SettingsSection.swift create mode 100644 Trio/Sources/Logger/Report/Sections/WatchSection.swift create mode 100644 Trio/Sources/Logger/Store/AppLog.xcdatamodeld/AppLog.xcdatamodel/contents create mode 100644 Trio/Sources/Logger/Store/AppLogStore.swift create mode 100644 Trio/Sources/Logger/Store/AppLogTextExporter.swift create mode 100644 Trio/Sources/Views/DeviceLogShareRow.swift create mode 100644 Trio/Sources/Views/ShareSheet.swift create mode 100644 TrioTests/Logging/AppLogStoreTests.swift create mode 100644 TrioTests/Logging/DeterminationsSectionTests.swift create mode 100644 TrioTests/Logging/DeviceLogExporterTests.swift create mode 100644 TrioTests/Logging/DeviceLogShareRequestTests.swift create mode 100644 TrioTests/Logging/DeviceLogStoreTests.swift create mode 100644 TrioTests/Logging/DiagnosticArchiverTests.swift create mode 100644 TrioTests/Logging/DiagnosticReportGeneratorTests.swift create mode 100644 TrioTests/Logging/LegacyFileLogSinkTests.swift create mode 100644 TrioTests/Logging/RedactorTests.swift create mode 100644 TrioTests/Logging/TrioLoggerTests.swift create mode 100644 TrioTests/Logging/WatchLogIngestTests.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index aa2d536b9..e8ddc797e 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -12,8 +12,13 @@ 03B15F4E3077CD6100DFEBE4 /* SyaiKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 03B15F4F3077CD6100DFEBE4 /* SyaiKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 041D1E995A6AE92E9289DC49 /* TreatmentsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D1A7CA8C10C4403D4BBFA7 /* TreatmentsDataFlow.swift */; }; + 06B768620A4F3877551BF0B8 /* LoopTimingSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DE70925909EFD810B65B94 /* LoopTimingSection.swift */; }; + 0847A90799FD02694AE3C447 /* LegacyFileLogSinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025C71818E3D84E0C32F60B3 /* LegacyFileLogSinkTests.swift */; }; + 0D1EAF43EA72BAE07D16FD25 /* NightscoutSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A818A5FB069B0396BED1EC46 /* NightscoutSection.swift */; }; 0D9A5E34A899219C5C4CDFAF /* HistoryStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9455FA2D92E77A6C4AFED8A3 /* HistoryStateModel.swift */; }; 10DAA318F47843A4B894173D /* ContainerResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98546920BB7247B0AC73449A /* ContainerResolutionTests.swift */; }; + 0F2B374F0AED7ECBAD5716B1 /* DeviceLogExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BF681E9D2A9755CB9DCD62 /* DeviceLogExporter.swift */; }; + 10C13662926D99DC9EDB0990 /* DeviceLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 260A8D718D2B6656B925FD08 /* DeviceLogStore.swift */; }; 110AEDE32C5193D200615CC9 /* BolusIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE02C5193D100615CC9 /* BolusIntent.swift */; }; 110AEDE42C5193D200615CC9 /* BolusIntentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE12C5193D100615CC9 /* BolusIntentRequest.swift */; }; 110AEDEB2C51A0AE00615CC9 /* ShortcutsConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE52C51A0AE00615CC9 /* ShortcutsConfigView.swift */; }; @@ -58,8 +63,14 @@ 19F95FFA29F1102A00314DDC /* StatRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19F95FF929F1102A00314DDC /* StatRootView.swift */; }; 1BBB001DAD60F3B8CEA4B1C7 /* ISFEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505E09DC17A0C3D0AF4B66FE /* ISFEditorStateModel.swift */; }; 1D845DF2E3324130E1D95E67 /* HistoryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60744C3E9BB3652895C908CC /* HistoryProvider.swift */; }; + 1F3B64442195D252B5D5C972 /* AppLogTextExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB6B0839018B69B643F2D2C /* AppLogTextExporter.swift */; }; 23888883D4EA091C88480FF2 /* TreatmentsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19984D62EFC0035A9E9644D /* TreatmentsProvider.swift */; }; 25726254CCC5496EB7BD95C8 /* CGMSensorStateLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41BEC89F0411CF3EF0F6A61B /* CGMSensorStateLog.swift */; }; + 27DE1FE3427339F87A4D1892 /* LaunchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24AF0AA0328EA5E4016BD4D6 /* LaunchID.swift */; }; + 281F422373124952B572A174 /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65598E41151729F57FA71BFC /* SettingsSection.swift */; }; + 2B205BE3D298C1265738E386 /* DeterminationsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEB7B02BB1F3747D8780E71 /* DeterminationsSection.swift */; }; + 2C9535979A6E92EE0EC83255 /* RedactorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 704720C410E0C4F7D10B184B /* RedactorTests.swift */; }; + 2D5DFD6C90037128EA3DF5A9 /* DeviceLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161D44BBD9BB82CE0AF6B4EC /* DeviceLogStoreTests.swift */; }; 320D030F724170A637F06D50 /* (null) in Sources */ = {isa = PBXBuildFile; }; 3811DE0B25C9D32F00A708ED /* BaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE0725C9D32E00A708ED /* BaseView.swift */; }; 3811DE0C25C9D32F00A708ED /* BaseProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE0825C9D32F00A708ED /* BaseProvider.swift */; }; @@ -100,6 +111,7 @@ 38192E04261B82FA0094D973 /* ReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38192E03261B82FA0094D973 /* ReachabilityManager.swift */; }; 38192E07261BA9960094D973 /* FetchTreatmentsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38192E06261BA9960094D973 /* FetchTreatmentsManager.swift */; }; 38192E0D261BAF980094D973 /* ConvenienceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38192E0C261BAF980094D973 /* ConvenienceExtensions.swift */; }; + 38205C536D1AB42976E6834C /* DeviceManagersSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6723682F37BD66333DBAEEC3 /* DeviceManagersSection.swift */; }; 3821ED4C25DD18BA00BC42AD /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3821ED4B25DD18BA00BC42AD /* Constants.swift */; }; 382C133725F13A1E00715CE1 /* InsulinSensitivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382C133625F13A1E00715CE1 /* InsulinSensitivities.swift */; }; 382C134B25F14E3700715CE1 /* BGTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382C134A25F14E3700715CE1 /* BGTargets.swift */; }; @@ -141,6 +153,32 @@ 38AEE73D25F0200C0013F05B /* TrioSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE73C25F0200C0013F05B /* TrioSettings.swift */; }; 38AEE75225F022080013F05B /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75125F022080013F05B /* SettingsManager.swift */; }; 38AEE75725F0F18E0013F05B /* CarbsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */; }; + 3B4D3E4C9C67D7C82D253132 /* LogShareAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25A78012C06C13F61394EEB /* LogShareAction.swift */; }; + 4ABD323927D718C0177C26B5 /* OSLogCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */; }; + 4E3B6212E43A3E90DD735B30 /* AppLog.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */; }; + 536B4DB89FCFE9188F4DFE5D /* RedactionPatterns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCB6F0602E069F5B4F83EDC /* RedactionPatterns.swift */; }; + 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */; }; + 60886ABC93CDB0D0EF6F7660 /* DeviceLogExporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0AE7DD84FC1252EAADC35F1 /* DeviceLogExporting.swift */; }; + 6ED6CA7C8BB78F440C2DDE04 /* BuildSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39C1F3299C2A3903B601D11 /* BuildSection.swift */; }; + 7539F9BBEDB0DC975CE0305E /* LegacyFileLogSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91BFCE121E9F8826CE05BDE /* LegacyFileLogSink.swift */; }; + 7BA97B74F25D6A1F5D853EFB /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F602845F4994CA0A57286EE6 /* ShareSheet.swift */; }; + 7D9C485831D451703B20F283 /* DeviceLogShareRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FABAFA208B56BAA140521720 /* DeviceLogShareRequestTests.swift */; }; + 8C9B2B65B1FF556ADF8915DF /* WatchSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AA5756BADDE99F52317A6D /* WatchSection.swift */; }; + 91AE5F35FC765DFE4C1A2589 /* AppLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */; }; + 91FC2FB975FB723C55744FB1 /* LogSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB5F206A024F911C4E145D5 /* LogSink.swift */; }; + 931AD2C542093B9D1A342065 /* InMemoryLogSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C171A8FC35B730F1FFF25F /* InMemoryLogSink.swift */; }; + A8354E8A31A4F482D0B649F1 /* Redactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0396B90CF23D28D2FDCD78 /* Redactor.swift */; }; + B797B55C99BEFD9F9433C576 /* AppLogSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D41475BAFA8F8CAFC04C5C /* AppLogSection.swift */; }; + BA411C5352750DB69633D080 /* DeviceLogSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E87AC5818D1F50CB16CAAB /* DeviceLogSection.swift */; }; + BD14CA4C88D904242178280A /* TrioLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7BC1040D2F0BD84E0718784 /* TrioLoggerTests.swift */; }; + BDAEE0006F95672099C25715 /* DiagnosticReportGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BA4CCF9E04B65D2624F103 /* DiagnosticReportGeneratorTests.swift */; }; + BDBAB3961A62E28A7C395887 /* DiagnosticArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CCD5DC2DBA49CD10579B99 /* DiagnosticArchiver.swift */; }; + C2E1829091B5A504901C6AB6 /* DeviceLogExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F42FDD4C077138ADA03C3CA /* DeviceLogExporterTests.swift */; }; + CAB5A1B2811F53317FFB4CD8 /* DiagnosticReportGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614C56186C5B8C3C99C57CE6 /* DiagnosticReportGenerator.swift */; }; + D3FE496564A6200CB6AE5EFA /* DiagnosticArchiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13038DD772B631A95414F658 /* DiagnosticArchiverTests.swift */; }; + DE6F63D93662C12AD18A3802 /* LogRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F0205BEC3117B5D00022C30 /* LogRecord.swift */; }; + E0F5C5534791F6BB9A237891 /* TrioLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69EF64E9534E4822199EA279 /* TrioLogger.swift */; }; + E5AC09ECB66AE8FA62A00878 /* AlertsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41E35FA1915BD201B4F7A0A2 /* AlertsSection.swift */; }; E73259BE7A7D1AFB93D6B8DF /* CarbEntryMutationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2F107674CF1D9C0A2A9ACF /* CarbEntryMutationService.swift */; }; 38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */ = {isa = PBXBuildFile; productRef = 38B17B6525DD90E0005CAE3D /* SwiftDate */; }; 38B4F3AF25E2979F00E76A18 /* IndexedCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B4F3AE25E2979F00E76A18 /* IndexedCollection.swift */; }; @@ -896,6 +934,13 @@ DD30BA1A2E08AB9F00DA677C /* CarbImpactParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */; }; DD32CF982CC82463003686D6 /* TrioRemoteControl+Bolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */; }; DD32CF9A2CC8247B003686D6 /* TrioRemoteControl+Meal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */; }; + EABED858C84BEB209F8A7076 /* DiagnosticReportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 633DDEAA939410C6120B9934 /* DiagnosticReportSection.swift */; }; + F0D6F2237EB1FB4862D0D4D7 /* AppLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39062D177D83C69CAFC9B2AC /* AppLogStore.swift */; }; + F60FCBDC5FB1457995D444EC /* KnownLogSubsystems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426EFF43AC38AB621583B2BC /* KnownLogSubsystems.swift */; }; + F636A48B266E10A9624A626F /* DiagnosticReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC672185C6C74E47114A2373 /* DiagnosticReportView.swift */; }; + F6854A961CB1CF1105757936 /* DeviceLogShareRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EB8FEFF04CF8327EE83D84 /* DeviceLogShareRow.swift */; }; + F6AB59B482B06C651B5DD91E /* DeterminationsSectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA4E33A9F1EA8D5126887CE /* DeterminationsSectionTests.swift */; }; + FC83BDFB7C330BFD6A4DECFC /* WatchLogIngestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */; }; FF0425EA9C0AFF6A6DF0B10D /* TrioRemoteControl+MealMutation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0480CC863D1BBC71668F34B3 /* TrioRemoteControl+MealMutation.swift */; }; DD32CF9C2CC82499003686D6 /* TrioRemoteControl+TempTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */; }; DD32CF9E2CC824C5003686D6 /* TrioRemoteControl+Override.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9D2CC824C2003686D6 /* TrioRemoteControl+Override.swift */; }; @@ -1093,6 +1138,7 @@ 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 */; }; + FF66A0A70765482014FBEF58 /* DeviceInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D37920C6EBE47C8404A7AF34 /* DeviceInfoSection.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1210,9 +1256,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 00DE70925909EFD810B65B94 /* LoopTimingSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopTimingSection.swift; sourceTree = ""; }; + 025C71818E3D84E0C32F60B3 /* LegacyFileLogSinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyFileLogSinkTests.swift; sourceTree = ""; }; 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0A1E8522E7684C6AA742F6E4 /* HomeStateModel+QuickPickTreatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeStateModel+QuickPickTreatments.swift"; sourceTree = ""; }; + 0CCB6F0602E069F5B4F83EDC /* RedactionPatterns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactionPatterns.swift; sourceTree = ""; }; + 0F0205BEC3117B5D00022C30 /* LogRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRecord.swift; sourceTree = ""; }; 10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadSerializer.swift; sourceTree = ""; }; 110AEDE02C5193D100615CC9 /* BolusIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusIntent.swift; sourceTree = ""; }; 110AEDE12C5193D100615CC9 /* BolusIntentRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusIntentRequest.swift; sourceTree = ""; }; @@ -1224,7 +1274,11 @@ 118DF7652C5ECBC60067FEB7 /* CancelOverrideIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CancelOverrideIntent.swift; sourceTree = ""; }; 118DF7672C5ECBC60067FEB7 /* OverridePresetEntity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverridePresetEntity.swift; sourceTree = ""; }; 118DF7682C5ECBC60067FEB7 /* OverridePresetsIntentRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverridePresetsIntentRequest.swift; sourceTree = ""; }; + 13038DD772B631A95414F658 /* DiagnosticArchiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticArchiverTests.swift; sourceTree = ""; }; 1526A587AD1544C495050662 /* QuickPickTreatmentsInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickTreatmentsInfoView.swift; sourceTree = ""; }; + 161D44BBD9BB82CE0AF6B4EC /* DeviceLogStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogStoreTests.swift; sourceTree = ""; }; + 16AA5756BADDE99F52317A6D /* WatchSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSection.swift; sourceTree = ""; }; + 17CCD5DC2DBA49CD10579B99 /* DiagnosticArchiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticArchiver.swift; sourceTree = ""; }; 19012CDB291D2CB900FB8210 /* LoopStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStats.swift; sourceTree = ""; }; 190EBCC329FF136900BA767D /* UserInterfaceSettingsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInterfaceSettingsDataFlow.swift; sourceTree = ""; }; 190EBCC529FF138000BA767D /* UserInterfaceSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInterfaceSettingsProvider.swift; sourceTree = ""; }; @@ -1256,7 +1310,11 @@ 19F95FF429F10FCF00314DDC /* StatProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatProvider.swift; sourceTree = ""; }; 19F95FF629F10FEE00314DDC /* StatStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatStateModel.swift; sourceTree = ""; }; 19F95FF929F1102A00314DDC /* StatRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatRootView.swift; sourceTree = ""; }; + 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRetentionPolicy.swift; sourceTree = ""; }; + 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogStoreTests.swift; sourceTree = ""; }; 223EC0494F55A91E3EA69EF4 /* TreatmentsStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TreatmentsStateModel.swift; sourceTree = ""; }; + 24AF0AA0328EA5E4016BD4D6 /* LaunchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchID.swift; sourceTree = ""; }; + 260A8D718D2B6656B925FD08 /* DeviceLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogStore.swift; sourceTree = ""; }; 2AD22C985B79A2F0D2EA3D9D /* PumpConfigRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigRootView.swift; sourceTree = ""; }; 2EEC5C2927FA4C5DA61A1A40 /* QuickPickTreatmentsConfigProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickTreatmentsConfigProvider.swift; sourceTree = ""; }; 2F2A13DF0EDEEEDC4106AA2A /* NightscoutConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigDataFlow.swift; sourceTree = ""; }; @@ -1354,6 +1412,7 @@ 38AEE73C25F0200C0013F05B /* TrioSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioSettings.swift; sourceTree = ""; }; 38AEE75125F022080013F05B /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsStorage.swift; sourceTree = ""; }; + 39062D177D83C69CAFC9B2AC /* AppLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogStore.swift; sourceTree = ""; }; 3A2F107674CF1D9C0A2A9ACF /* CarbEntryMutationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryMutationService.swift; sourceTree = ""; }; 38B4F3AE25E2979F00E76A18 /* IndexedCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexedCollection.swift; sourceTree = ""; }; 38B4F3C225E2A20B00E76A18 /* PumpSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpSetupView.swift; sourceTree = ""; }; @@ -1416,6 +1475,7 @@ 38FEF3FB2737E53800574A46 /* MainStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainStateModel.swift; sourceTree = ""; }; 38FEF3FD2738083E00574A46 /* CGMSettingsProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMSettingsProvider.swift; sourceTree = ""; }; 38FEF412273B317A00574A46 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; + 3B0396B90CF23D28D2FDCD78 /* Redactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Redactor.swift; sourceTree = ""; }; 3B0B4E6B2DE1439A005C6627 /* LockedResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockedResolver.swift; sourceTree = ""; }; 3B139EF22DF06CDC00D40797 /* AutosensGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutosensGenerator.swift; sourceTree = ""; }; 3B1C5C242D68E1E3004E9273 /* IobCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobCalculation.swift; sourceTree = ""; }; @@ -1517,9 +1577,14 @@ 3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = ""; }; 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = ""; }; 41BEC89F0411CF3EF0F6A61B /* CGMSensorStateLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMSensorStateLog.swift; sourceTree = ""; }; + 41E35FA1915BD201B4F7A0A2 /* AlertsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsSection.swift; sourceTree = ""; }; 42369F66CF91F30624C0B3A6 /* BasalProfileEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorProvider.swift; sourceTree = ""; }; + 426EFF43AC38AB621583B2BC /* KnownLogSubsystems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnownLogSubsystems.swift; sourceTree = ""; }; + 43BF681E9D2A9755CB9DCD62 /* DeviceLogExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogExporter.swift; sourceTree = ""; }; 44080E4709E3AE4B73054563 /* ConfigEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorProvider.swift; sourceTree = ""; }; + 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCapture.swift; sourceTree = ""; }; 48B83503461B4F8D97B30115 /* SettingsSearchHighlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchHighlight.swift; sourceTree = ""; }; + 48E87AC5818D1F50CB16CAAB /* DeviceLogSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogSection.swift; sourceTree = ""; }; 49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminWatchSettings.swift; sourceTree = ""; }; 491D6FB92D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetRunStored+CoreDataClass.swift"; sourceTree = ""; }; 491D6FBA2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetRunStored+CoreDataProperties.swift"; sourceTree = ""; }; @@ -1532,6 +1597,7 @@ 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 = ""; }; + 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogIngestTests.swift; sourceTree = ""; }; 581516A32BCED84A00BF67D7 /* DebuggingIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingIdentifiers.swift; sourceTree = ""; }; 581516A82BCEEDF800BF67D7 /* NSPredicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSPredicates.swift; sourceTree = ""; }; 581AC4382BE22ED10038760C /* JSONConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONConverter.swift; sourceTree = ""; }; @@ -1572,9 +1638,16 @@ 5E12FE0E2EBC0000000000A1 /* LibreLoop.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LibreLoop.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5E12FE0E2EBC0000000000A2 /* LibreLoopUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LibreLoopUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 60744C3E9BB3652895C908CC /* HistoryProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryProvider.swift; sourceTree = ""; }; + 614C56186C5B8C3C99C57CE6 /* DiagnosticReportGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticReportGenerator.swift; sourceTree = ""; }; + 633DDEAA939410C6120B9934 /* DiagnosticReportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticReportSection.swift; sourceTree = ""; }; 64AA5E04A2761F6EEA6568E1 /* CarbRatioEditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarbRatioEditorStateModel.swift; sourceTree = ""; }; 65070A322BFDCB83006F213F /* TidepoolStartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolStartView.swift; sourceTree = ""; }; + 65598E41151729F57FA71BFC /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; + 6723682F37BD66333DBAEEC3 /* DeviceManagersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagersSection.swift; sourceTree = ""; }; 67F94DD2853CF42BA4E30616 /* BasalProfileEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorDataFlow.swift; sourceTree = ""; }; + 69EF64E9534E4822199EA279 /* TrioLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioLogger.swift; sourceTree = ""; }; + 6AB077E2E3EC75CCBDEFF8B5 /* AppLog.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AppLog.xcdatamodel; sourceTree = ""; }; + 6AB6B0839018B69B643F2D2C /* AppLogTextExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogTextExporter.swift; sourceTree = ""; }; 6B1A8D012B14D88B00E76752 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; 6B1A8D172B14D91600E76752 /* LiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 6B1A8D182B14D91600E76752 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -1585,7 +1658,17 @@ 6B1A8D252B14D91700E76752 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6B1A8D2D2B156EEF00E76752 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; 6BCF84DC2B16843A003AD46E /* LiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityAttributes.swift; sourceTree = ""; }; + 6F42FDD4C077138ADA03C3CA /* DeviceLogExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogExporterTests.swift; sourceTree = ""; }; + 704720C410E0C4F7D10B184B /* RedactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactorTests.swift; sourceTree = ""; }; + 7AA4E33A9F1EA8D5126887CE /* DeterminationsSectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationsSectionTests.swift; sourceTree = ""; }; 7E2CF3BABA4BF1C48CC96FD7 /* LiveActivityGlucoseStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityGlucoseStyle.swift; sourceTree = ""; }; + 9FEB7B02BB1F3747D8780E71 /* DeterminationsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationsSection.swift; sourceTree = ""; }; + A39C1F3299C2A3903B601D11 /* BuildSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildSection.swift; sourceTree = ""; }; + A818A5FB069B0396BED1EC46 /* NightscoutSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSection.swift; sourceTree = ""; }; + ABB5F206A024F911C4E145D5 /* LogSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogSink.swift; sourceTree = ""; }; + B7D41475BAFA8F8CAFC04C5C /* AppLogSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogSection.swift; sourceTree = ""; }; + B8BA4CCF9E04B65D2624F103 /* DiagnosticReportGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticReportGeneratorTests.swift; sourceTree = ""; }; + C7BC1040D2F0BD84E0718784 /* TrioLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioLoggerTests.swift; sourceTree = ""; }; CD6672C97F2AB74125F46DD5 /* LiveActivitySimpleWidgetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySimpleWidgetConfiguration.swift; sourceTree = ""; }; 71D44AAA2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertPermissionsChecker.swift; sourceTree = ""; }; 79BDA519C9B890FD9A5DFCF3 /* ISFEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorDataFlow.swift; sourceTree = ""; }; @@ -2014,6 +2097,8 @@ CEE9A65B2BBB41C800EB5194 /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.swift; sourceTree = ""; }; CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationsTests.swift; sourceTree = ""; }; D0BDC6993C1087310EDFC428 /* CarbRatioEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarbRatioEditorRootView.swift; sourceTree = ""; }; + D25A78012C06C13F61394EEB /* LogShareAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogShareAction.swift; sourceTree = ""; }; + D37920C6EBE47C8404A7AF34 /* DeviceInfoSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoSection.swift; sourceTree = ""; }; D543DE17E0054AF0821411BB /* QuickPickTreatmentsConfigDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickTreatmentsConfigDataFlow.swift; sourceTree = ""; }; D7C5E1F4A7B91E2F3C5D6E71 /* TimeInterval+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Convenience.swift"; sourceTree = ""; }; DB0BA0010000000000000002 /* DevelopmentBranchAlerter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevelopmentBranchAlerter.swift; sourceTree = ""; }; @@ -2250,6 +2335,7 @@ E00EEC0227368630002FF094 /* NetworkAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkAssembly.swift; sourceTree = ""; }; E013D871273AC6FE0014109C /* GlucoseSimulatorSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSimulatorSource.swift; sourceTree = ""; }; E06B9119275B5EEA003C04B6 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; + E0AE7DD84FC1252EAADC35F1 /* DeviceLogExporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogExporting.swift; sourceTree = ""; }; E0CC2C5B275B9DAE00A7BC71 /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = System/Library/Frameworks/HealthKit.framework; sourceTree = SDKROOT; }; E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSample.swift; sourceTree = ""; }; E592A36F2CEEC01E009A472C /* ContactTrickEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickEntry.swift; sourceTree = ""; }; @@ -2257,6 +2343,9 @@ E592A3732CEEC038009A472C /* ContactImageDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageDataFlow.swift; sourceTree = ""; }; E592A3742CEEC038009A472C /* ContactImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageProvider.swift; sourceTree = ""; }; E592A3752CEEC038009A472C /* ContactImageStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageStateModel.swift; sourceTree = ""; }; + E9C171A8FC35B730F1FFF25F /* InMemoryLogSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryLogSink.swift; sourceTree = ""; }; + F602845F4994CA0A57286EE6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; + F7EB8FEFF04CF8327EE83D84 /* DeviceLogShareRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogShareRow.swift; sourceTree = ""; }; F816825D28DB441200054060 /* HeartBeatManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartBeatManager.swift; sourceTree = ""; }; F816825F28DB441800054060 /* BluetoothTransmitter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothTransmitter.swift; sourceTree = ""; }; F90692A9274B7AAE0037068D /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = ""; }; @@ -2264,12 +2353,15 @@ F90692D0274B99B60037068D /* HealthKitProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitProvider.swift; sourceTree = ""; }; F90692D2274B9A130037068D /* AppleHealthKitRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleHealthKitRootView.swift; sourceTree = ""; }; F90692D5274B9A450037068D /* HealthKitStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitStateModel.swift; sourceTree = ""; }; + F91BFCE121E9F8826CE05BDE /* LegacyFileLogSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyFileLogSink.swift; sourceTree = ""; }; FAB1E00000000000000000B1 /* ParityHarness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityHarness.swift; sourceTree = ""; }; FAB1E00000000000000000B2 /* ParityInputs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityInputs.swift; sourceTree = ""; }; FAB1E00000000000000000B3 /* ParityScenarios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityScenarios.swift; sourceTree = ""; }; FAB1E00000000000000000B4 /* ParityGoldenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityGoldenTests.swift; sourceTree = ""; }; FAB1E00000000000000000B5 /* ParityPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityPerformanceTests.swift; sourceTree = ""; }; + FABAFA208B56BAA140521720 /* DeviceLogShareRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogShareRequestTests.swift; sourceTree = ""; }; FBB3BAE7494CB771ABAC7B8B /* ISFEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorRootView.swift; sourceTree = ""; }; + FC672185C6C74E47114A2373 /* DiagnosticReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticReportView.swift; sourceTree = ""; }; FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutPreferences.swift; sourceTree = ""; }; FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = ""; }; @@ -3139,6 +3231,8 @@ DD500A712807000000000100 /* SnoozeAlertsSheetView.swift */, 7A11AC0DE000000000000170 /* GlassActionSheet.swift */, 7A11AC0DE000000000000014 /* ManualGlucoseEntryView.swift */, + F602845F4994CA0A57286EE6 /* ShareSheet.swift */, + F7EB8FEFF04CF8327EE83D84 /* DeviceLogShareRow.swift */, ); path = Views; sourceTree = ""; @@ -3429,6 +3523,12 @@ 38E98A1C25F52C9300C0CED0 /* Logger.swift */, 38E98A1D25F52C9300C0CED0 /* IssueReporter */, 38E98A2225F52C9300C0CED0 /* Error+Extensions.swift */, + 79A6FC1583EE7761756C86BD /* Contracts */, + 9F14EBDAAE691A7D9C7913DE /* Core */, + B57248ACB19D6554EBCAA15E /* DeviceLog */, + 9EFA1E4C7EB7203F0D39B585 /* Export */, + 5BA8CACAF5028913393A78CB /* Store */, + 56A0C4CEFE19625056490BB2 /* Report */, ); path = Logger; sourceTree = ""; @@ -3514,6 +3614,7 @@ CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */, CD1235A000000000000000F1 /* TidepoolUploadSerializerTests.swift */, CE1425A000000000000000A1 /* CarbsEntryLoopKitTests.swift */, + A5D6B6D83F32CE8BBDDB4E5B /* Logging */, ); path = TrioTests; sourceTree = ""; @@ -3722,6 +3823,18 @@ path = View; sourceTree = ""; }; + 56A0C4CEFE19625056490BB2 /* Report */ = { + isa = PBXGroup; + children = ( + 9AAF3F0F84429E86D5AC0E3B /* Sections */, + 614C56186C5B8C3C99C57CE6 /* DiagnosticReportGenerator.swift */, + 3B0396B90CF23D28D2FDCD78 /* Redactor.swift */, + 0CCB6F0602E069F5B4F83EDC /* RedactionPatterns.swift */, + FC672185C6C74E47114A2373 /* DiagnosticReportView.swift */, + ); + path = Report; + sourceTree = ""; + }; 5825D1622BD405AE00F36E9B /* Helper */ = { isa = PBXGroup; children = ( @@ -3780,6 +3893,16 @@ path = Model; sourceTree = ""; }; + 5BA8CACAF5028913393A78CB /* Store */ = { + isa = PBXGroup; + children = ( + 39062D177D83C69CAFC9B2AC /* AppLogStore.swift */, + 6AB6B0839018B69B643F2D2C /* AppLogTextExporter.swift */, + 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */, + ); + path = Store; + sourceTree = ""; + }; 6517011F19F244F64E1FF14B /* TargetsEditor */ = { isa = PBXGroup; children = ( @@ -3816,6 +3939,20 @@ path = LiveActivity; sourceTree = ""; }; + 79A6FC1583EE7761756C86BD /* Contracts */ = { + isa = PBXGroup; + children = ( + 0F0205BEC3117B5D00022C30 /* LogRecord.swift */, + ABB5F206A024F911C4E145D5 /* LogSink.swift */, + 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */, + E0AE7DD84FC1252EAADC35F1 /* DeviceLogExporting.swift */, + 633DDEAA939410C6120B9934 /* DiagnosticReportSection.swift */, + 17CCD5DC2DBA49CD10579B99 /* DiagnosticArchiver.swift */, + 426EFF43AC38AB621583B2BC /* KnownLogSubsystems.swift */, + ); + path = Contracts; + sourceTree = ""; + }; 99C01B871ACAB3F32CE755C7 /* PumpConfig */ = { isa = PBXGroup; children = ( @@ -3827,6 +3964,24 @@ path = PumpConfig; sourceTree = ""; }; + 9AAF3F0F84429E86D5AC0E3B /* Sections */ = { + isa = PBXGroup; + children = ( + A39C1F3299C2A3903B601D11 /* BuildSection.swift */, + D37920C6EBE47C8404A7AF34 /* DeviceInfoSection.swift */, + 65598E41151729F57FA71BFC /* SettingsSection.swift */, + 6723682F37BD66333DBAEEC3 /* DeviceManagersSection.swift */, + 41E35FA1915BD201B4F7A0A2 /* AlertsSection.swift */, + 9FEB7B02BB1F3747D8780E71 /* DeterminationsSection.swift */, + 00DE70925909EFD810B65B94 /* LoopTimingSection.swift */, + A818A5FB069B0396BED1EC46 /* NightscoutSection.swift */, + 16AA5756BADDE99F52317A6D /* WatchSection.swift */, + B7D41475BAFA8F8CAFC04C5C /* AppLogSection.swift */, + 48E87AC5818D1F50CB16CAAB /* DeviceLogSection.swift */, + ); + path = Sections; + sourceTree = ""; + }; 9E56E3626FAD933385101B76 /* History */ = { isa = PBXGroup; children = ( @@ -3841,6 +3996,27 @@ path = History; sourceTree = ""; }; + 9EFA1E4C7EB7203F0D39B585 /* Export */ = { + isa = PBXGroup; + children = ( + 43BF681E9D2A9755CB9DCD62 /* DeviceLogExporter.swift */, + 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */, + D25A78012C06C13F61394EEB /* LogShareAction.swift */, + ); + path = Export; + sourceTree = ""; + }; + 9F14EBDAAE691A7D9C7913DE /* Core */ = { + isa = PBXGroup; + children = ( + 69EF64E9534E4822199EA279 /* TrioLogger.swift */, + E9C171A8FC35B730F1FFF25F /* InMemoryLogSink.swift */, + F91BFCE121E9F8826CE05BDE /* LegacyFileLogSink.swift */, + 24AF0AA0328EA5E4016BD4D6 /* LaunchID.swift */, + ); + path = Core; + sourceTree = ""; + }; A42F1FEDFFD0DDE00AAD54D3 /* BasalProfileEditor */ = { isa = PBXGroup; children = ( @@ -3852,6 +4028,24 @@ path = BasalProfileEditor; sourceTree = ""; }; + A5D6B6D83F32CE8BBDDB4E5B /* Logging */ = { + isa = PBXGroup; + children = ( + 13038DD772B631A95414F658 /* DiagnosticArchiverTests.swift */, + 161D44BBD9BB82CE0AF6B4EC /* DeviceLogStoreTests.swift */, + 6F42FDD4C077138ADA03C3CA /* DeviceLogExporterTests.swift */, + C7BC1040D2F0BD84E0718784 /* TrioLoggerTests.swift */, + 025C71818E3D84E0C32F60B3 /* LegacyFileLogSinkTests.swift */, + 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */, + 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */, + B8BA4CCF9E04B65D2624F103 /* DiagnosticReportGeneratorTests.swift */, + 7AA4E33A9F1EA8D5126887CE /* DeterminationsSectionTests.swift */, + 704720C410E0C4F7D10B184B /* RedactorTests.swift */, + FABAFA208B56BAA140521720 /* DeviceLogShareRequestTests.swift */, + ); + path = Logging; + sourceTree = ""; + }; B015AFE22E500000000D7351 /* BolusSafety */ = { isa = PBXGroup; children = ( @@ -3884,6 +4078,14 @@ path = AdjustmentsTests; sourceTree = ""; }; + B57248ACB19D6554EBCAA15E /* DeviceLog */ = { + isa = PBXGroup; + children = ( + 260A8D718D2B6656B925FD08 /* DeviceLogStore.swift */, + ); + path = DeviceLog; + sourceTree = ""; + }; B9488883C59C31550E0B4CEC /* View */ = { isa = PBXGroup; children = ( @@ -6301,6 +6503,41 @@ BD175EBE000010000000000B /* HistoryRootView+Filters.swift in Sources */, BD175EBE000010000000000C /* HistoryRootView+AddGlucose.swift in Sources */, BD175EBE000010000000000D /* HistoryRootView+Confirmations.swift in Sources */, + DE6F63D93662C12AD18A3802 /* LogRecord.swift in Sources */, + 91FC2FB975FB723C55744FB1 /* LogSink.swift in Sources */, + 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */, + 60886ABC93CDB0D0EF6F7660 /* DeviceLogExporting.swift in Sources */, + EABED858C84BEB209F8A7076 /* DiagnosticReportSection.swift in Sources */, + BDBAB3961A62E28A7C395887 /* DiagnosticArchiver.swift in Sources */, + F60FCBDC5FB1457995D444EC /* KnownLogSubsystems.swift in Sources */, + E0F5C5534791F6BB9A237891 /* TrioLogger.swift in Sources */, + 931AD2C542093B9D1A342065 /* InMemoryLogSink.swift in Sources */, + 7539F9BBEDB0DC975CE0305E /* LegacyFileLogSink.swift in Sources */, + 27DE1FE3427339F87A4D1892 /* LaunchID.swift in Sources */, + 10C13662926D99DC9EDB0990 /* DeviceLogStore.swift in Sources */, + 0F2B374F0AED7ECBAD5716B1 /* DeviceLogExporter.swift in Sources */, + 4ABD323927D718C0177C26B5 /* OSLogCapture.swift in Sources */, + 3B4D3E4C9C67D7C82D253132 /* LogShareAction.swift in Sources */, + F0D6F2237EB1FB4862D0D4D7 /* AppLogStore.swift in Sources */, + 1F3B64442195D252B5D5C972 /* AppLogTextExporter.swift in Sources */, + CAB5A1B2811F53317FFB4CD8 /* DiagnosticReportGenerator.swift in Sources */, + A8354E8A31A4F482D0B649F1 /* Redactor.swift in Sources */, + 536B4DB89FCFE9188F4DFE5D /* RedactionPatterns.swift in Sources */, + F636A48B266E10A9624A626F /* DiagnosticReportView.swift in Sources */, + 6ED6CA7C8BB78F440C2DDE04 /* BuildSection.swift in Sources */, + FF66A0A70765482014FBEF58 /* DeviceInfoSection.swift in Sources */, + 281F422373124952B572A174 /* SettingsSection.swift in Sources */, + 38205C536D1AB42976E6834C /* DeviceManagersSection.swift in Sources */, + E5AC09ECB66AE8FA62A00878 /* AlertsSection.swift in Sources */, + 2B205BE3D298C1265738E386 /* DeterminationsSection.swift in Sources */, + 06B768620A4F3877551BF0B8 /* LoopTimingSection.swift in Sources */, + 0D1EAF43EA72BAE07D16FD25 /* NightscoutSection.swift in Sources */, + 8C9B2B65B1FF556ADF8915DF /* WatchSection.swift in Sources */, + B797B55C99BEFD9F9433C576 /* AppLogSection.swift in Sources */, + BA411C5352750DB69633D080 /* DeviceLogSection.swift in Sources */, + 7BA97B74F25D6A1F5D853EFB /* ShareSheet.swift in Sources */, + F6854A961CB1CF1105757936 /* DeviceLogShareRow.swift in Sources */, + 4E3B6212E43A3E90DD735B30 /* AppLog.xcdatamodeld in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6413,6 +6650,17 @@ CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */, CD1235B000000000000000F2 /* TidepoolUploadSerializerTests.swift in Sources */, CE1425B000000000000000A2 /* CarbsEntryLoopKitTests.swift in Sources */, + D3FE496564A6200CB6AE5EFA /* DiagnosticArchiverTests.swift in Sources */, + 2D5DFD6C90037128EA3DF5A9 /* DeviceLogStoreTests.swift in Sources */, + C2E1829091B5A504901C6AB6 /* DeviceLogExporterTests.swift in Sources */, + BD14CA4C88D904242178280A /* TrioLoggerTests.swift in Sources */, + 0847A90799FD02694AE3C447 /* LegacyFileLogSinkTests.swift in Sources */, + 91AE5F35FC765DFE4C1A2589 /* AppLogStoreTests.swift in Sources */, + FC83BDFB7C330BFD6A4DECFC /* WatchLogIngestTests.swift in Sources */, + BDAEE0006F95672099C25715 /* DiagnosticReportGeneratorTests.swift in Sources */, + F6AB59B482B06C651B5DD91E /* DeterminationsSectionTests.swift in Sources */, + 2C9535979A6E92EE0EC83255 /* RedactorTests.swift in Sources */, + 7D9C485831D451703B20F283 /* DeviceLogShareRequestTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -7260,6 +7508,16 @@ /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ + 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 6AB077E2E3EC75CCBDEFF8B5 /* AppLog.xcdatamodel */, + ); + currentVersion = 6AB077E2E3EC75CCBDEFF8B5 /* AppLog.xcdatamodel */; + path = AppLog.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; DDD1631D2C4C6F6900CD525A /* TrioCoreDataPersistentContainer.xcdatamodeld */ = { isa = XCVersionGroup; children = ( diff --git a/Trio/Sources/Logger/Contracts/DeviceLogExporting.swift b/Trio/Sources/Logger/Contracts/DeviceLogExporting.swift new file mode 100644 index 000000000..81139b2aa --- /dev/null +++ b/Trio/Sources/Logger/Contracts/DeviceLogExporting.swift @@ -0,0 +1,12 @@ +import Foundation + +struct DeviceLogExportRequest { + var window: DateInterval + var managerIdentifier: String? + var includeOSLog: Bool + var includeKitFiles: Bool +} + +protocol DeviceLogExporting { + func export(_ request: DeviceLogExportRequest, into directory: URL) async throws +} diff --git a/Trio/Sources/Logger/Contracts/DiagnosticArchiver.swift b/Trio/Sources/Logger/Contracts/DiagnosticArchiver.swift new file mode 100644 index 000000000..66f0e1c42 --- /dev/null +++ b/Trio/Sources/Logger/Contracts/DiagnosticArchiver.swift @@ -0,0 +1,28 @@ +import Foundation + +enum DiagnosticArchiver { + static func zip(_ directory: URL, name: String) throws -> URL { + let fileManager = FileManager.default + let destination = fileManager.temporaryDirectory.appendingPathComponent("\(name).zip") + var coordinationError: NSError? + var copyError: Error? + + NSFileCoordinator().coordinate(readingItemAt: directory, options: .forUploading, error: &coordinationError) { zipURL in + do { + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + try fileManager.copyItem(at: zipURL, to: destination) + } catch { + copyError = error + } + } + + if let coordinationError { throw coordinationError } + if let copyError { throw copyError } + guard fileManager.fileExists(atPath: destination.path) else { + throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: destination.path]) + } + return destination + } +} diff --git a/Trio/Sources/Logger/Contracts/DiagnosticReportSection.swift b/Trio/Sources/Logger/Contracts/DiagnosticReportSection.swift new file mode 100644 index 000000000..a1dc34293 --- /dev/null +++ b/Trio/Sources/Logger/Contracts/DiagnosticReportSection.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol DiagnosticReportSection { + var title: String { get } + var order: Int { get } + func render(window: DateInterval, into directory: URL) async -> String +} diff --git a/Trio/Sources/Logger/Contracts/KnownLogSubsystems.swift b/Trio/Sources/Logger/Contracts/KnownLogSubsystems.swift new file mode 100644 index 000000000..e8a530523 --- /dev/null +++ b/Trio/Sources/Logger/Contracts/KnownLogSubsystems.swift @@ -0,0 +1,24 @@ +import Foundation + +enum KnownLogSubsystems { + static let all: [String] = [Bundle.main.bundleIdentifier].compactMap { $0 } + [ + "com.loopkit.OmnipodKit", + "com.randallknutson.OmniBLE", + "com.loopkit.MinimedKit", + "com.loopkit.CGMBLEKit", + "org.loopkit.G7SensorKit", + "org.loopkit.G7SensorKitUI", + "org.loopkit.G7SensorPlugin", + "com.ps2.rileylink", + "com.loopkit.LibreTransmitter", + "com.loopkit.libre", + "com.randallknutson.DanaKit", + "org.nightscout.MedtrumKit", + "org.nightscout.EversenseKit", + "com.bastiaanv.AccuChekKit", + "org.loopkit.LibreLoop", + "com.loopkit.ShareClient", + "org.tidepool.TidepoolService", + "com.loopkit.LoopKit" + ] +} diff --git a/Trio/Sources/Logger/Contracts/LogRecord.swift b/Trio/Sources/Logger/Contracts/LogRecord.swift new file mode 100644 index 000000000..8ecda1998 --- /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 000000000..d01387a88 --- /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: 7 * 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 000000000..db6cb301c --- /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 000000000..2f5105b9a --- /dev/null +++ b/Trio/Sources/Logger/Core/InMemoryLogSink.swift @@ -0,0 +1,11 @@ +import Foundation + +final class InMemoryLogSink: LogSink { + func accept(_: LogRecord) {} + + func flush() async {} + + func records(in _: DateInterval, minimumLevel _: LogLevel) async -> [LogRecord] { + [] + } +} diff --git a/Trio/Sources/Logger/Core/LaunchID.swift b/Trio/Sources/Logger/Core/LaunchID.swift new file mode 100644 index 000000000..4f0d43804 --- /dev/null +++ b/Trio/Sources/Logger/Core/LaunchID.swift @@ -0,0 +1,5 @@ +import Foundation + +enum LaunchID { + static let current = UUID() +} diff --git a/Trio/Sources/Logger/Core/LegacyFileLogSink.swift b/Trio/Sources/Logger/Core/LegacyFileLogSink.swift new file mode 100644 index 000000000..19acfdee9 --- /dev/null +++ b/Trio/Sources/Logger/Core/LegacyFileLogSink.swift @@ -0,0 +1,11 @@ +import Foundation + +final class LegacyFileLogSink: LogSink { + func accept(_: LogRecord) {} + + func flush() async {} + + func records(in _: DateInterval, minimumLevel _: LogLevel) async -> [LogRecord] { + [] + } +} diff --git a/Trio/Sources/Logger/Core/TrioLogger.swift b/Trio/Sources/Logger/Core/TrioLogger.swift new file mode 100644 index 000000000..9e9dc4c3e --- /dev/null +++ b/Trio/Sources/Logger/Core/TrioLogger.swift @@ -0,0 +1,3 @@ +import Foundation + +struct TrioLogger {} diff --git a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift new file mode 100644 index 000000000..8bfced380 --- /dev/null +++ b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift @@ -0,0 +1,3 @@ +import Foundation + +final class DeviceLogStore {} diff --git a/Trio/Sources/Logger/Export/DeviceLogExporter.swift b/Trio/Sources/Logger/Export/DeviceLogExporter.swift new file mode 100644 index 000000000..d7fb57b56 --- /dev/null +++ b/Trio/Sources/Logger/Export/DeviceLogExporter.swift @@ -0,0 +1,5 @@ +import Foundation + +final class DeviceLogExporter: DeviceLogExporting { + func export(_: DeviceLogExportRequest, into _: URL) async throws {} +} diff --git a/Trio/Sources/Logger/Export/LogShareAction.swift b/Trio/Sources/Logger/Export/LogShareAction.swift new file mode 100644 index 000000000..6df1ea56d --- /dev/null +++ b/Trio/Sources/Logger/Export/LogShareAction.swift @@ -0,0 +1,3 @@ +import Foundation + +enum LogShareAction {} diff --git a/Trio/Sources/Logger/Export/OSLogCapture.swift b/Trio/Sources/Logger/Export/OSLogCapture.swift new file mode 100644 index 000000000..a96378b15 --- /dev/null +++ b/Trio/Sources/Logger/Export/OSLogCapture.swift @@ -0,0 +1,3 @@ +import Foundation + +enum OSLogCapture {} diff --git a/Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift b/Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift new file mode 100644 index 000000000..80b801f74 --- /dev/null +++ b/Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift @@ -0,0 +1,3 @@ +import Foundation + +final class DiagnosticReportGenerator {} diff --git a/Trio/Sources/Logger/Report/DiagnosticReportView.swift b/Trio/Sources/Logger/Report/DiagnosticReportView.swift new file mode 100644 index 000000000..651848cd7 --- /dev/null +++ b/Trio/Sources/Logger/Report/DiagnosticReportView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct DiagnosticReportView: View { + var body: some View { + EmptyView() + } +} diff --git a/Trio/Sources/Logger/Report/RedactionPatterns.swift b/Trio/Sources/Logger/Report/RedactionPatterns.swift new file mode 100644 index 000000000..3677104dc --- /dev/null +++ b/Trio/Sources/Logger/Report/RedactionPatterns.swift @@ -0,0 +1,3 @@ +import Foundation + +enum RedactionPatterns {} diff --git a/Trio/Sources/Logger/Report/Redactor.swift b/Trio/Sources/Logger/Report/Redactor.swift new file mode 100644 index 000000000..ac0a1c98c --- /dev/null +++ b/Trio/Sources/Logger/Report/Redactor.swift @@ -0,0 +1,3 @@ +import Foundation + +enum Redactor {} diff --git a/Trio/Sources/Logger/Report/Sections/AlertsSection.swift b/Trio/Sources/Logger/Report/Sections/AlertsSection.swift new file mode 100644 index 000000000..c5908f787 --- /dev/null +++ b/Trio/Sources/Logger/Report/Sections/AlertsSection.swift @@ -0,0 +1,3 @@ +import Foundation + +struct AlertsSection {} diff --git a/Trio/Sources/Logger/Report/Sections/AppLogSection.swift b/Trio/Sources/Logger/Report/Sections/AppLogSection.swift new file mode 100644 index 000000000..3f3110c65 --- /dev/null +++ b/Trio/Sources/Logger/Report/Sections/AppLogSection.swift @@ -0,0 +1,3 @@ +import Foundation + +struct AppLogSection {} diff --git a/Trio/Sources/Logger/Report/Sections/BuildSection.swift b/Trio/Sources/Logger/Report/Sections/BuildSection.swift new file mode 100644 index 000000000..3605ab46f --- /dev/null +++ b/Trio/Sources/Logger/Report/Sections/BuildSection.swift @@ -0,0 +1,3 @@ +import Foundation + +struct BuildSection {} diff --git a/Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift b/Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift new file mode 100644 index 000000000..1a7bb8990 --- /dev/null +++ b/Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift @@ -0,0 +1,3 @@ +import Foundation + +struct DeterminationsSection {} diff --git a/Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift b/Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift new file mode 100644 index 000000000..84161103b --- /dev/null +++ b/Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift @@ -0,0 +1,3 @@ +import Foundation + +struct DeviceInfoSection {} diff --git a/Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift b/Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift new file mode 100644 index 000000000..25d1c65f2 --- /dev/null +++ b/Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift @@ -0,0 +1,3 @@ +import Foundation + +struct DeviceLogSection {} diff --git a/Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift b/Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift new file mode 100644 index 000000000..db07d4ddd --- /dev/null +++ b/Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift @@ -0,0 +1,3 @@ +import Foundation + +struct DeviceManagersSection {} diff --git a/Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift b/Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift new file mode 100644 index 000000000..ec4de9bd6 --- /dev/null +++ b/Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift @@ -0,0 +1,3 @@ +import Foundation + +struct LoopTimingSection {} diff --git a/Trio/Sources/Logger/Report/Sections/NightscoutSection.swift b/Trio/Sources/Logger/Report/Sections/NightscoutSection.swift new file mode 100644 index 000000000..f3cf6c005 --- /dev/null +++ b/Trio/Sources/Logger/Report/Sections/NightscoutSection.swift @@ -0,0 +1,3 @@ +import Foundation + +struct NightscoutSection {} diff --git a/Trio/Sources/Logger/Report/Sections/SettingsSection.swift b/Trio/Sources/Logger/Report/Sections/SettingsSection.swift new file mode 100644 index 000000000..6d7c68f21 --- /dev/null +++ b/Trio/Sources/Logger/Report/Sections/SettingsSection.swift @@ -0,0 +1,3 @@ +import Foundation + +struct SettingsSection {} diff --git a/Trio/Sources/Logger/Report/Sections/WatchSection.swift b/Trio/Sources/Logger/Report/Sections/WatchSection.swift new file mode 100644 index 000000000..27d30b03e --- /dev/null +++ b/Trio/Sources/Logger/Report/Sections/WatchSection.swift @@ -0,0 +1,3 @@ +import Foundation + +struct WatchSection {} 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 000000000..9fc13bb6f --- /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 000000000..f2ebc763d --- /dev/null +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -0,0 +1,3 @@ +import Foundation + +actor AppLogStore {} diff --git a/Trio/Sources/Logger/Store/AppLogTextExporter.swift b/Trio/Sources/Logger/Store/AppLogTextExporter.swift new file mode 100644 index 000000000..f97239b27 --- /dev/null +++ b/Trio/Sources/Logger/Store/AppLogTextExporter.swift @@ -0,0 +1,3 @@ +import Foundation + +enum AppLogTextExporter {} diff --git a/Trio/Sources/Models/TrioSettings.swift b/Trio/Sources/Models/TrioSettings.swift index 797a6bf0a..e03f4188b 100644 --- a/Trio/Sources/Models/TrioSettings.swift +++ b/Trio/Sources/Models/TrioSettings.swift @@ -89,6 +89,9 @@ struct TrioSettings: JSON, Equatable, Encodable { /// Controls whether watchface data transmission is enabled var isWatchfaceDataEnabled: Bool = false + var logRetentionDays: Int = 7 + var logVerboseEnabled: Bool = true + /// Computed property that groups all Garmin settings into a single struct var garminSettings: GarminWatchSettings { get { @@ -402,6 +405,14 @@ extension TrioSettings: Decodable { settings.isWatchfaceDataEnabled = isWatchfaceDataEnabled } + if let logRetentionDays = try? container.decode(Int.self, forKey: .logRetentionDays) { + settings.logRetentionDays = logRetentionDays + } + + if let logVerboseEnabled = try? container.decode(Bool.self, forKey: .logVerboseEnabled) { + settings.logVerboseEnabled = logVerboseEnabled + } + self = settings } } diff --git a/Trio/Sources/Modules/ConfigEditor/View/ConfigEditorRootView.swift b/Trio/Sources/Modules/ConfigEditor/View/ConfigEditorRootView.swift index 188879fed..10d5a82ef 100644 --- a/Trio/Sources/Modules/ConfigEditor/View/ConfigEditorRootView.swift +++ b/Trio/Sources/Modules/ConfigEditor/View/ConfigEditorRootView.swift @@ -47,27 +47,3 @@ extension ConfigEditor { } } } - -struct ShareSheet: UIViewControllerRepresentable { - typealias Callback = (_ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error?) - -> Void - - let activityItems: [Any] - let applicationActivities: [UIActivity]? = nil - let excludedActivityTypes: [UIActivity.ActivityType]? = nil - let callback: Callback? = nil - - func makeUIViewController(context _: Context) -> UIActivityViewController { - let controller = UIActivityViewController( - activityItems: activityItems, - applicationActivities: applicationActivities - ) - controller.excludedActivityTypes = excludedActivityTypes - controller.completionWithItemsHandler = callback - return controller - } - - func updateUIViewController(_: UIActivityViewController, context _: Context) { - // nothing to do here - } -} diff --git a/Trio/Sources/Views/DeviceLogShareRow.swift b/Trio/Sources/Views/DeviceLogShareRow.swift new file mode 100644 index 000000000..f931fb468 --- /dev/null +++ b/Trio/Sources/Views/DeviceLogShareRow.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct DeviceLogShareRow: View { + var body: some View { + EmptyView() + } +} diff --git a/Trio/Sources/Views/ShareSheet.swift b/Trio/Sources/Views/ShareSheet.swift new file mode 100644 index 000000000..fd7c2cf81 --- /dev/null +++ b/Trio/Sources/Views/ShareSheet.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct ShareSheet: UIViewControllerRepresentable { + typealias Callback = (_ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error?) + -> Void + + let activityItems: [Any] + let applicationActivities: [UIActivity]? = nil + let excludedActivityTypes: [UIActivity.ActivityType]? = nil + let callback: Callback? = nil + + func makeUIViewController(context _: Context) -> UIActivityViewController { + let controller = UIActivityViewController( + activityItems: activityItems, + applicationActivities: applicationActivities + ) + controller.excludedActivityTypes = excludedActivityTypes + controller.completionWithItemsHandler = callback + return controller + } + + func updateUIViewController(_: UIActivityViewController, context _: Context) { + // nothing to do here + } +} diff --git a/TrioTests/Logging/AppLogStoreTests.swift b/TrioTests/Logging/AppLogStoreTests.swift new file mode 100644 index 000000000..b9323381c --- /dev/null +++ b/TrioTests/Logging/AppLogStoreTests.swift @@ -0,0 +1,6 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("App Log Store Tests") struct AppLogStoreTests {} diff --git a/TrioTests/Logging/DeterminationsSectionTests.swift b/TrioTests/Logging/DeterminationsSectionTests.swift new file mode 100644 index 000000000..ea0258e74 --- /dev/null +++ b/TrioTests/Logging/DeterminationsSectionTests.swift @@ -0,0 +1,6 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Determinations Section Tests") struct DeterminationsSectionTests {} diff --git a/TrioTests/Logging/DeviceLogExporterTests.swift b/TrioTests/Logging/DeviceLogExporterTests.swift new file mode 100644 index 000000000..4a56b570f --- /dev/null +++ b/TrioTests/Logging/DeviceLogExporterTests.swift @@ -0,0 +1,6 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Device Log Exporter Tests") struct DeviceLogExporterTests {} diff --git a/TrioTests/Logging/DeviceLogShareRequestTests.swift b/TrioTests/Logging/DeviceLogShareRequestTests.swift new file mode 100644 index 000000000..e15f33b38 --- /dev/null +++ b/TrioTests/Logging/DeviceLogShareRequestTests.swift @@ -0,0 +1,6 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Device Log Share Request Tests") struct DeviceLogShareRequestTests {} diff --git a/TrioTests/Logging/DeviceLogStoreTests.swift b/TrioTests/Logging/DeviceLogStoreTests.swift new file mode 100644 index 000000000..5cf09ce5c --- /dev/null +++ b/TrioTests/Logging/DeviceLogStoreTests.swift @@ -0,0 +1,6 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Device Log Store Tests") struct DeviceLogStoreTests {} diff --git a/TrioTests/Logging/DiagnosticArchiverTests.swift b/TrioTests/Logging/DiagnosticArchiverTests.swift new file mode 100644 index 000000000..bc5df62f5 --- /dev/null +++ b/TrioTests/Logging/DiagnosticArchiverTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Diagnostic Archiver Tests") struct DiagnosticArchiverTests { + @Test("Zips a directory into a valid archive") func testZipDirectory() throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directory) } + + try Data("first".utf8).write(to: directory.appendingPathComponent("first.txt")) + try Data("second".utf8).write(to: directory.appendingPathComponent("second.txt")) + + let archive = try DiagnosticArchiver.zip(directory, name: "archiver-test-\(UUID().uuidString)") + defer { try? fileManager.removeItem(at: archive) } + + #expect(fileManager.fileExists(atPath: archive.path)) + #expect(archive.pathExtension == "zip") + let data = try Data(contentsOf: archive) + #expect(data.count > 22) + #expect(Array(data.prefix(4)) == [0x50, 0x4B, 0x03, 0x04]) + } +} diff --git a/TrioTests/Logging/DiagnosticReportGeneratorTests.swift b/TrioTests/Logging/DiagnosticReportGeneratorTests.swift new file mode 100644 index 000000000..de3aa4815 --- /dev/null +++ b/TrioTests/Logging/DiagnosticReportGeneratorTests.swift @@ -0,0 +1,6 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Diagnostic Report Generator Tests") struct DiagnosticReportGeneratorTests {} diff --git a/TrioTests/Logging/LegacyFileLogSinkTests.swift b/TrioTests/Logging/LegacyFileLogSinkTests.swift new file mode 100644 index 000000000..01524eda9 --- /dev/null +++ b/TrioTests/Logging/LegacyFileLogSinkTests.swift @@ -0,0 +1,6 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Legacy File Log Sink Tests") struct LegacyFileLogSinkTests {} diff --git a/TrioTests/Logging/RedactorTests.swift b/TrioTests/Logging/RedactorTests.swift new file mode 100644 index 000000000..e8f94389f --- /dev/null +++ b/TrioTests/Logging/RedactorTests.swift @@ -0,0 +1,6 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Redactor Tests") struct RedactorTests {} diff --git a/TrioTests/Logging/TrioLoggerTests.swift b/TrioTests/Logging/TrioLoggerTests.swift new file mode 100644 index 000000000..85edbd2d2 --- /dev/null +++ b/TrioTests/Logging/TrioLoggerTests.swift @@ -0,0 +1,6 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Trio Logger Tests") struct TrioLoggerTests {} diff --git a/TrioTests/Logging/WatchLogIngestTests.swift b/TrioTests/Logging/WatchLogIngestTests.swift new file mode 100644 index 000000000..419acd97b --- /dev/null +++ b/TrioTests/Logging/WatchLogIngestTests.swift @@ -0,0 +1,6 @@ +import Foundation +import Testing + +@testable import Trio + +@Suite("Watch Log Ingest Tests") struct WatchLogIngestTests {} From 152856649ee1374b1c6c990d8a5218ef09e67044 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:54:17 +0200 Subject: [PATCH 06/48] Persist the LoopKit device log --- Trio/Sources/APS/CGM/PluginSource.swift | 18 +- Trio/Sources/APS/DeviceDataManager.swift | 17 +- Trio/Sources/APS/FetchGlucoseManager.swift | 7 +- Trio/Sources/Assemblies/APSAssembly.swift | 2 + .../Logger/DeviceLog/DeviceLogStore.swift | 204 +++++++++++++++++- .../Logger/Export/DeviceLogExporter.swift | 11 +- .../Logger/Export/LogShareAction.swift | 6 +- TrioTests/Logging/DeviceLogStoreTests.swift | 122 ++++++++++- 8 files changed, 372 insertions(+), 15 deletions(-) diff --git a/Trio/Sources/APS/CGM/PluginSource.swift b/Trio/Sources/APS/CGM/PluginSource.swift index 49671c023..d23bebc43 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? let cgmDisplayState = CurrentValueSubject(nil) @@ -24,8 +25,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 @@ -94,13 +96,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` diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index f50550e03..89abaf721 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 @@ -652,13 +653,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 92f0b0a65..187fcc440 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() @@ -275,7 +276,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 + ) } } diff --git a/Trio/Sources/Assemblies/APSAssembly.swift b/Trio/Sources/Assemblies/APSAssembly.swift index 0f0672694..372d031bf 100644 --- a/Trio/Sources/Assemblies/APSAssembly.swift +++ b/Trio/Sources/Assemblies/APSAssembly.swift @@ -3,6 +3,8 @@ import Swinject final class APSAssembly: Assembly { func assemble(container: Container) { + container.register(DeviceLogStore.self) { _ in DeviceLogStore() }.inObjectScope(.container) + container.register(DeviceLogExporting.self) { r in DeviceLogExporter(resolver: r) } 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/Logger/DeviceLog/DeviceLogStore.swift b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift index 8bfced380..b3cf7edc2 100644 --- a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift +++ b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift @@ -1,3 +1,205 @@ +import CoreData import Foundation +import LoopKit +import UIKit -final class DeviceLogStore {} +final class DeviceLogStore: @unchecked Sendable { + static let bufferLimit = 5000 + + 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 didMoveStoreAside = false + + 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 { + guard let deviceLog = self.deviceLog else { + continuation.resume(returning: []) + 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, !isCheckingProtectedData else { 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) + } + } + } + + private func recover(protectedDataAvailable: Bool) { + guard deviceLog == nil, protectedDataAvailable, !didMoveStoreAside else { return } + didMoveStoreAside = true + moveStoreAside() + if preflight(storageURL) { + open() + } else { + warning(.deviceManager, "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() + deviceLog.purgeLogEntries(before: Date().addingTimeInterval(-maxAge)) + } + + 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(.deviceManager, "Could not move device log file aside: \(error)") + } + } + } + + static func preflight(_ url: URL) -> Bool { + autoreleasepool { + guard let modelURL = Bundle(for: PersistentDeviceLog.self).url(forResource: "DeviceLog", withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) + else { return false } + 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 coordinator = container.persistentStoreCoordinator + for store in coordinator.persistentStores { + try? coordinator.remove(store) + } + return loadError == nil + } + } +} diff --git a/Trio/Sources/Logger/Export/DeviceLogExporter.swift b/Trio/Sources/Logger/Export/DeviceLogExporter.swift index d7fb57b56..a6a01256e 100644 --- a/Trio/Sources/Logger/Export/DeviceLogExporter.swift +++ b/Trio/Sources/Logger/Export/DeviceLogExporter.swift @@ -1,5 +1,14 @@ import Foundation +import Swinject + +final class DeviceLogExporter: DeviceLogExporting, Injectable { + @Injected() private var deviceLogStore: DeviceLogStore! + @Injected() private var deviceDataManager: DeviceDataManager! + @Injected() private var fetchGlucoseManager: FetchGlucoseManager! + + init(resolver: Resolver) { + injectServices(resolver) + } -final class DeviceLogExporter: DeviceLogExporting { func export(_: DeviceLogExportRequest, into _: URL) async throws {} } diff --git a/Trio/Sources/Logger/Export/LogShareAction.swift b/Trio/Sources/Logger/Export/LogShareAction.swift index 6df1ea56d..be33d35ee 100644 --- a/Trio/Sources/Logger/Export/LogShareAction.swift +++ b/Trio/Sources/Logger/Export/LogShareAction.swift @@ -1,3 +1,7 @@ import Foundation -enum LogShareAction {} +enum LogShareAction { + static func archive(named _: String, contents _: @escaping (URL) async throws -> Void) async throws -> URL { + throw CocoaError(.featureUnsupported) + } +} diff --git a/TrioTests/Logging/DeviceLogStoreTests.swift b/TrioTests/Logging/DeviceLogStoreTests.swift index 5cf09ce5c..22bed2547 100644 --- a/TrioTests/Logging/DeviceLogStoreTests.swift +++ b/TrioTests/Logging/DeviceLogStoreTests.swift @@ -1,6 +1,126 @@ import Foundation +import LoopKit import Testing +import UIKit @testable import Trio -@Suite("Device Log Store Tests") struct DeviceLogStoreTests {} +@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) + } + } + } + + private static func waitUntil(_ condition: () async -> Bool) async -> Bool { + for _ in 0 ..< 100 { + 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 == 2 }) + #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).isEmpty) + + preflight.setSucceeds(true) + center.post(name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + + #expect(await Self.waitUntil { await store.entries(in: Self.window).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)) + } +} From efd24bce9c693fc965af2ff474841e910986ae6b Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:45:48 +0200 Subject: [PATCH 07/48] Route logging through os.Logger and a sink --- Trio.xcodeproj/project.pbxproj | 16 -- .../OpenAPSSwift/AlgorithmLoggingShim.swift | 24 ++ Trio/Sources/Application/TrioApp.swift | 1 + Trio/Sources/Assemblies/ServiceAssembly.swift | 8 - .../Sources/Logger/Core/InMemoryLogSink.swift | 34 ++- .../Logger/Core/LegacyFileLogSink.swift | 100 ++++++- Trio/Sources/Logger/Core/TrioLogger.swift | 72 ++++- Trio/Sources/Logger/Error+Extensions.swift | 5 - .../CollectionIssueReporter.swift | 54 ---- .../Logger/IssueReporter/IssueReporter.swift | 14 - .../IssueReporter/SimpleLogReporter.swift | 56 +--- Trio/Sources/Logger/Logger.swift | 251 ++---------------- Trio/Sources/Logger/Signpost.swift | 150 ----------- .../Alerts/CriticalAlertAlarmScheduler.swift | 32 +-- .../Alerts/CriticalAlertAudioPlayer.swift | 20 +- .../Logging/LegacyFileLogSinkTests.swift | 33 ++- TrioTests/Logging/TrioLoggerTests.swift | 78 +++++- 17 files changed, 366 insertions(+), 582 deletions(-) delete mode 100644 Trio/Sources/Logger/Error+Extensions.swift delete mode 100644 Trio/Sources/Logger/IssueReporter/CollectionIssueReporter.swift delete mode 100644 Trio/Sources/Logger/IssueReporter/IssueReporter.swift delete mode 100644 Trio/Sources/Logger/Signpost.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index e8ddc797e..1e1cf1209 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -218,11 +218,7 @@ 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 */; }; @@ -1451,11 +1447,7 @@ 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 = ""; }; @@ -3519,10 +3511,8 @@ 38E98A1A25F52C9300C0CED0 /* Logger */ = { isa = PBXGroup; children = ( - 38E98A1B25F52C9300C0CED0 /* Signpost.swift */, 38E98A1C25F52C9300C0CED0 /* Logger.swift */, 38E98A1D25F52C9300C0CED0 /* IssueReporter */, - 38E98A2225F52C9300C0CED0 /* Error+Extensions.swift */, 79A6FC1583EE7761756C86BD /* Contracts */, 9F14EBDAAE691A7D9C7913DE /* Core */, B57248ACB19D6554EBCAA15E /* DeviceLog */, @@ -3536,8 +3526,6 @@ 38E98A1D25F52C9300C0CED0 /* IssueReporter */ = { isa = PBXGroup; children = ( - 38E98A1E25F52C9300C0CED0 /* IssueReporter.swift */, - 38E98A2025F52C9300C0CED0 /* CollectionIssueReporter.swift */, 38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */, ); path = IssueReporter; @@ -5934,7 +5922,6 @@ 38A0363B25ECF07E00FCBB52 /* GlucoseStorage.swift in Sources */, 65070A332BFDCB83006F213F /* TidepoolStartView.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 */, @@ -6032,7 +6019,6 @@ 7A11AC0DE000000000000110 /* HomeRootView+BottomControls.swift in Sources */, 7A11AC0DE00000000000010E /* HomeRootView+MealPanel.swift in Sources */, 7A11AC0DE00000000000010D /* HomeRootView+Header.swift in Sources */, - 38E98A2925F52C9300C0CED0 /* Error+Extensions.swift in Sources */, 3E62C7822F54CC1B00433237 /* BolusDisplayThreshold.swift in Sources */, 38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */, 3811DE6125C9D4D500A708ED /* ViewModifiers.swift in Sources */, @@ -6149,7 +6135,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 */, @@ -6309,7 +6294,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 */, diff --git a/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift b/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift index 13b0d0539..603dfa17c 100644 --- a/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift +++ b/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift @@ -67,4 +67,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/Application/TrioApp.swift b/Trio/Sources/Application/TrioApp.swift index 7b224221c..7c7989db4 100644 --- a/Trio/Sources/Application/TrioApp.swift +++ b/Trio/Sources/Application/TrioApp.swift @@ -128,6 +128,7 @@ extension Notification.Name { } init() { + TrioLogger.install(LegacyFileLogSink()) FileProtectionFixer.fixFlagFileProtectionForPropertyPersistentFlags() // TODO: ‼️ REMOVE ME BEFORE PUBLIC BETA / RELEASE let notificationCenter = Foundation.NotificationCenter.default diff --git a/Trio/Sources/Assemblies/ServiceAssembly.swift b/Trio/Sources/Assemblies/ServiceAssembly.swift index c5602c538..f31a6427d 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) } diff --git a/Trio/Sources/Logger/Core/InMemoryLogSink.swift b/Trio/Sources/Logger/Core/InMemoryLogSink.swift index 2f5105b9a..dea1b0b56 100644 --- a/Trio/Sources/Logger/Core/InMemoryLogSink.swift +++ b/Trio/Sources/Logger/Core/InMemoryLogSink.swift @@ -1,11 +1,39 @@ import Foundation +import os final class InMemoryLogSink: LogSink { - func accept(_: LogRecord) {} + 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 _: DateInterval, minimumLevel _: LogLevel) async -> [LogRecord] { - [] + 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[.. [LogRecord] { [] } + + private func write(_ record: LogRecord) { + let startOfDay = calendar.startOfDay(for: record.timestamp) + if handleDay != startOfDay { + try? handle?.close() + handle = openFile(startOfDay: startOfDay) + handleDay = handle == nil ? nil : startOfDay + } + guard let handle else { return } + + let line = + "\(formatter.string(from: record.timestamp)) [\(record.category)] \(record.file) - \(record.function) - \(record.line) - \(record.level.linePrefix)\(record.message)\n" + do { + try handle.write(contentsOf: Data(line.utf8)) + } catch { + try? handle.close() + self.handle = nil + handleDay = nil + } + } + + private func openFile(startOfDay: Date) -> FileHandle? { + if !fileManager.fileExists(atPath: directory.path) { + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + + if fileManager.fileExists(atPath: fileURL.path) { + if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path), + let creationDate = attributes[.creationDate] as? Date, creationDate < startOfDay + { + try? fileManager.removeItem(at: previousFileURL) + try? fileManager.moveItem(at: fileURL, to: previousFileURL) + fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: [.creationDate: startOfDay]) + } + } else { + fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: [.creationDate: startOfDay]) + } + + guard let handle = try? FileHandle(forWritingTo: fileURL) else { return nil } + _ = try? handle.seekToEnd() + return handle + } +} + +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: " + } + } } diff --git a/Trio/Sources/Logger/Core/TrioLogger.swift b/Trio/Sources/Logger/Core/TrioLogger.swift index 9e9dc4c3e..85330cc1e 100644 --- a/Trio/Sources/Logger/Core/TrioLogger.swift +++ b/Trio/Sources/Logger/Core/TrioLogger.swift @@ -1,3 +1,73 @@ import Foundation +import os -struct TrioLogger {} +enum TrioLogger { + private struct State: Sendable { + var sink: any LogSink + var installed: Bool + } + + private static let earlySink = InMemoryLogSink() + private static let state = OSAllocatedUnfairLock(initialState: State(sink: earlySink, installed: false)) + + static var sink: any LogSink { + state.withLock { $0.sink } + } + + static func install(_ sink: any LogSink) { + state.withLock { state in + // Replay records logged before the first install so they still reach the real sink. + if !state.installed { + earlySink.snapshot().forEach(sink.accept) + } + state.sink = sink + state.installed = true + } + } + + static func log( + _ level: LogLevel, + _ category: Logger.Category, + _ message: String, + printToConsole: Bool = true, + file: String, + function: String, + line: UInt + ) { + let record = LogRecord( + timestamp: Date(), + 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 String { + var lastPathComponentFast: String { + guard let slash = lastIndex(of: "/") else { return self } + return String(self[index(after: slash)...]) + } +} diff --git a/Trio/Sources/Logger/Error+Extensions.swift b/Trio/Sources/Logger/Error+Extensions.swift deleted file mode 100644 index 9da857748..000000000 --- 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/IssueReporter/CollectionIssueReporter.swift b/Trio/Sources/Logger/IssueReporter/CollectionIssueReporter.swift deleted file mode 100644 index 9b7e553ef..000000000 --- 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 a7b0b7a1b..000000000 --- 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 index 254d723ec..3067bbbc6 100644 --- a/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift +++ b/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift @@ -1,57 +1,7 @@ import Foundation -import SwiftDate import System -final class SimpleLogReporter: IssueReporter { - private let fileManager = FileManager.default - - private var dateFormatter: DateFormatter { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" - return dateFormatter - } - - 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 = Date() - let startOfDay = Calendar.current.startOfDay(for: now) - - if !fileManager.fileExists(atPath: SimpleLogReporter.logDir) { - try? fileManager.createDirectory( - atPath: SimpleLogReporter.logDir, - withIntermediateDirectories: false, - attributes: nil - ) - } - - if !fileManager.fileExists(atPath: SimpleLogReporter.logFile) { - createFile(at: startOfDay) - } else { - if let attributes = try? fileManager.attributesOfItem(atPath: SimpleLogReporter.logFile), - let creationDate = attributes[.creationDate] as? Date, creationDate < startOfDay - { - try? fileManager.removeItem(atPath: SimpleLogReporter.logFilePrev) - try? fileManager.moveItem(atPath: SimpleLogReporter.logFile, toPath: SimpleLogReporter.logFilePrev) - createFile(at: startOfDay) - } - } - - let logEntry = "\(dateFormatter.string(from: now)) [\(category)] \(file.file) - \(function) - \(line) - \(message)\n" - let data = logEntry.data(using: .utf8)! - try? data.append(fileURL: URL(fileURLWithPath: SimpleLogReporter.logFile)) - } - - private func createFile(at date: Date) { - fileManager.createFile(atPath: SimpleLogReporter.logFile, contents: nil, attributes: [.creationDate: date]) - } - +enum SimpleLogReporter { static var logFile: String { getDocumentsDirectory().appendingPathComponent("logs/log.txt").path } @@ -122,7 +72,3 @@ private extension Data { 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 93c13ddfc..cace6cada 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 = String(describing: maybeError.loggerError(message: message, withDescription: description)) + TrioLogger.log(.warning, category, message, file: file, function: function, line: line) } func error( @@ -69,55 +42,26 @@ 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 = String(describing: maybeError.loggerError(message: message, withDescription: description)) + 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 = String(describing: maybeError.loggerError(message: message, withDescription: description)) + 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 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 { +enum Logger { + enum Category: String, CaseIterable { case `default` case service case businessLogic @@ -135,44 +79,6 @@ final class Logger { 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 .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, - .remoteControl, - .service, - .storage, - .telemetry, - .watchManager: - return OSLog(subsystem: subsystem, category: name) - } - } } fileprivate enum Error: Swift.Error { @@ -180,125 +86,6 @@ final class Logger { 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()) } } @@ -316,7 +103,3 @@ private extension Optional where Wrapped == Swift.Error { } } } - -private extension String { - var file: String { components(separatedBy: "/").last ?? "" } -} diff --git a/Trio/Sources/Logger/Signpost.swift b/Trio/Sources/Logger/Signpost.swift deleted file mode 100644 index 9a5ef17c6..000000000 --- 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/Services/Alerts/CriticalAlertAlarmScheduler.swift b/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift index c5e02f7aa..bf95c5a04 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 { + error(.service, "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(.service, "Scheduled AlarmKit alarm \(id.uuidString) for \(identifier.value)") + } catch let scheduleError { + error( + .service, + "Failed to schedule AlarmKit alarm for \(identifier.value) — 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 - ) + info(.service, "Cancelled AlarmKit alarm \(id.uuidString) for \(identifier.value)") #endif } } diff --git a/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift b/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift index e3965f242..00f87bf2c 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(.service, "Neither \(soundName) nor critical.caf found in main bundle; 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(.service, "AVAudioPlayer.play() returned false 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(.service, "Started critical-alert audio playback (duration=\(String(format: "%.2f", p.duration))s)") + } catch let playbackError { + error(.service, "Failed to start audio playback", 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) + info(.service, "Stopped critical-alert audio playback") } } diff --git a/TrioTests/Logging/LegacyFileLogSinkTests.swift b/TrioTests/Logging/LegacyFileLogSinkTests.swift index 01524eda9..c61d93ed1 100644 --- a/TrioTests/Logging/LegacyFileLogSinkTests.swift +++ b/TrioTests/Logging/LegacyFileLogSinkTests.swift @@ -3,4 +3,35 @@ import Testing @testable import Trio -@Suite("Legacy File Log Sink Tests") struct LegacyFileLogSinkTests {} +@Suite("Legacy File Log Sink Tests") struct LegacyFileLogSinkTests { + @Test("Writes lines in order after flush") func testWritesInOrder() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let sink = LegacyFileLogSink(directory: directory) + let timestamp = Date() + for index in 0 ..< 100 { + sink.accept(LogRecord( + timestamp: timestamp, + level: index.isMultiple(of: 2) ? .debug : .warning, + category: "Service", + message: "line \(index)", + file: "File.swift", + function: "run()", + line: UInt(index), + launchID: LaunchID.current + )) + } + await sink.flush() + + let contents = try String(contentsOf: sink.fileURL, encoding: .utf8) + let lines = contents.split(separator: "\n").map(String.init) + #expect(lines.count == 100) + #expect(lines.first?.hasSuffix("[Service] File.swift - run() - 0 - DEV: line 0") == true) + #expect(lines.dropFirst().first?.hasSuffix("[Service] File.swift - run() - 1 - WARN: line 1") == true) + #expect(lines.enumerated().allSatisfy { $0.element.hasSuffix("line \($0.offset)") }) + + let stamp = try #require(lines.first?.split(separator: " ").first) + #expect(stamp.contains(".")) + } +} diff --git a/TrioTests/Logging/TrioLoggerTests.swift b/TrioTests/Logging/TrioLoggerTests.swift index 85edbd2d2..36b3eaf7d 100644 --- a/TrioTests/Logging/TrioLoggerTests.swift +++ b/TrioTests/Logging/TrioLoggerTests.swift @@ -3,4 +3,80 @@ import Testing @testable import Trio -@Suite("Trio Logger Tests") struct TrioLoggerTests {} +@Suite("Trio Logger Tests", .serialized) struct TrioLoggerTests { + private func capture(_ body: () -> Void) -> [LogRecord] { + let previous = TrioLogger.sink + let sink = InMemoryLogSink(capacity: 20000) + TrioLogger.install(sink) + defer { TrioLogger.install(previous) } + 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(.openAPS, "debug \(token)") + info(.service, "info \(token)") + warning(.nightscout, "warning \(token)") + error(.coreData, "error \(token)") + fault(.default, "fault \(token)") + }.filter { $0.message.contains(token) } + + #expect(records.map(\.level) == [.debug, .info, .warning, .error, .fault]) + #expect(records.map(\.category) == ["OpenAPS", "Service", "Nightscout", "CoreData", "Default"]) + #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 + let records = capture { + error(.service, "error returns", description: "detail") + reached = true + } + #expect(reached) + #expect(records.last?.message.contains("detail") == true) + } + + @Test("Debug without console output still reaches the sink") func testPrintToConsoleFalse() { + let records = capture { + debug(.service, "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(.businessLogic, "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: "Default", + message: "\(index)", + file: "File.swift", + function: "f()", + line: 1, + launchID: LaunchID.current + )) + } + #expect(sink.snapshot().map(\.message) == ["2", "3", "4"]) + } +} From 34947f9861d02d3793100b946e3a669d9f606719 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:06:45 +0200 Subject: [PATCH 08/48] Export device logs from Settings and device screens --- .../Localizations/Main/Localizable.xcstrings | 18 ++ .../Logger/Export/DeviceLogExporter.swift | 121 +++++++++++- .../Logger/Export/LogShareAction.swift | 42 ++++- Trio/Sources/Logger/Export/OSLogCapture.swift | 61 +++++- .../CGMSettings/CGMSettingsStateModel.swift | 1 + .../CGMSettings/View/CGMRootView.swift | 16 ++ .../PumpConfig/PumpConfigStateModel.swift | 1 + .../PumpConfig/View/PumpConfigRootView.swift | 13 ++ .../Modules/Settings/SettingsStateModel.swift | 35 +++- .../Settings/View/SettingsRootView.swift | 55 +++++- Trio/Sources/Views/DeviceLogShareRow.swift | 98 +++++++++- .../Logging/DeviceLogExporterTests.swift | 178 +++++++++++++++++- .../Logging/DeviceLogShareRequestTests.swift | 14 +- 13 files changed, 631 insertions(+), 22 deletions(-) diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 7bc0ec118..b38448954 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -49856,6 +49856,9 @@ } } } + }, + "App logs" : { + }, "App version, build date, branch, and commit SHA; whether this is a TestFlight build; an Apple-supplied per-vendor identifier (IDFV) and a per-install UUID; hardware identifier (e.g. iPhone15,2); platform and iOS version; the paired pump model (when a pump is configured); the paired CGM type and model (when a CGM is configured); whether Nightscout, Tidepool, and Apple Health are configured (yes/no — no URLs, tokens, or credentials); and a few preference flags (units, closed-loop on/off, Live Activity, calendar integration). A rolling cold-launch count is included. The full JSON is visible under App Diagnostics → What's sent." : { "localizations" : { @@ -90581,6 +90584,9 @@ } } } + }, + "Could not prepare logs" : { + }, "Could not save the change." : { "comment" : "Adjustment could not be saved", @@ -110327,6 +110333,9 @@ } } } + }, + "Device logs" : { + }, "Devices" : { "comment" : "Devices menu item in the Settings main view.", @@ -170194,6 +170203,9 @@ } } } + }, + "Includes everything this device reported to Trio in the last 3 days. Low-level Bluetooth traces cover the current app session only." : { + }, "Increase sensitivity when glucose is above target if a manual Temp Target > %@ %@ is set." : { "localizations" : { @@ -268241,6 +268253,9 @@ } } } + }, + "Share CGM Logs" : { + }, "Share logs" : { "comment" : "2 log files to share", @@ -268639,6 +268654,9 @@ } } } + }, + "Share Pump Logs" : { + }, "Shared Secret" : { "localizations" : { diff --git a/Trio/Sources/Logger/Export/DeviceLogExporter.swift b/Trio/Sources/Logger/Export/DeviceLogExporter.swift index a6a01256e..2d46c7a1e 100644 --- a/Trio/Sources/Logger/Export/DeviceLogExporter.swift +++ b/Trio/Sources/Logger/Export/DeviceLogExporter.swift @@ -1,4 +1,5 @@ import Foundation +import LoopKit import Swinject final class DeviceLogExporter: DeviceLogExporting, Injectable { @@ -6,9 +7,127 @@ final class DeviceLogExporter: DeviceLogExporting, Injectable { @Injected() private var deviceDataManager: DeviceDataManager! @Injected() private var fetchGlucoseManager: FetchGlucoseManager! + static let kitFolders = ["danakit", "medtrumkit", "eversense", "accuchek", "libreloop"] + private static let sliceDuration: TimeInterval = 6 * 60 * 60 + + private struct ManagerSnapshot { + let identifier: String + let title: String + let description: String + } + + private let documentsDirectory: URL + private var managerProvider: (() -> [DeviceManager])? + + private let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + init(resolver: Resolver) { + documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] injectServices(resolver) } - func export(_: DeviceLogExportRequest, into _: URL) async throws {} + init(store: DeviceLogStore, documentsDirectory: URL, managers: @escaping () -> [DeviceManager]) { + self.documentsDirectory = documentsDirectory + managerProvider = managers + deviceLogStore = store + } + + func export(_ request: DeviceLogExportRequest, into directory: URL) async throws { + try await exportDeviceLogs(request, into: directory) + if request.includeOSLog { + let since = max(request.window.start, OSLogCapture.processStartDate ?? request.window.start) + OSLogCapture.write(subsystems: KnownLogSubsystems.all, since: since, into: directory) + } + if request.includeKitFiles { + copyKitFiles(into: directory) + } + } + + @MainActor private func liveManagers() -> [ManagerSnapshot] { + let managers: [DeviceManager] + if let managerProvider { + managers = managerProvider() + } else { + let candidates: [DeviceManager?] = [deviceDataManager?.pumpManager, fetchGlucoseManager?.cgmManager] + managers = candidates.compactMap { $0 } + } + return managers.map { + ManagerSnapshot(identifier: $0.pluginIdentifier, title: $0.localizedTitle, description: String(reflecting: $0)) + } + } + + private func exportDeviceLogs(_ request: DeviceLogExportRequest, into directory: URL) async throws { + let snapshots = await liveManagers().filter { + request.managerIdentifier == nil || $0.identifier == request.managerIdentifier + } + var handles: [String: FileHandle] = [:] + defer { handles.values.forEach { try? $0.close() } } + + func handle(for identifier: String) throws -> FileHandle { + if let handle = handles[identifier] { return handle } + let header = snapshots.first { $0.identifier == identifier } + .map { "# \($0.title) (\(identifier))\n\n```\n\($0.description)\n```\n\n" } + ?? "# \(identifier)\n\n" + let url = directory.appendingPathComponent("DeviceLog-\(identifier).txt") + guard FileManager.default.createFile(atPath: url.path, contents: Data(header.utf8)) else { + throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: url.path]) + } + let handle = try FileHandle(forWritingTo: url) + try handle.seekToEnd() + handles[identifier] = handle + return handle + } + + for snapshot in snapshots { + _ = try handle(for: snapshot.identifier) + } + if let identifier = request.managerIdentifier { + _ = try handle(for: identifier) + } + + guard let deviceLogStore else { return } + var sliceStart = request.window.start + while sliceStart < request.window.end { + let sliceEnd = min(sliceStart.addingTimeInterval(Self.sliceDuration), request.window.end) + let entries = await deviceLogStore.entries( + in: DateInterval(start: sliceStart, end: sliceEnd), + managerIdentifier: request.managerIdentifier + ) + for entry in entries { + try handle(for: entry.managerIdentifier).write(contentsOf: Data(line(for: entry).utf8)) + } + sliceStart = sliceEnd + } + } + + private func line(for entry: StoredDeviceLogEntry) -> String { + let timestamp = dateFormatter.string(from: entry.timestamp) + let device = entry.deviceIdentifier ?? "-" + return "* \(timestamp) \(entry.managerIdentifier) \(device) \(entry.type.rawValue) \(entry.message)\n" + } + + private func copyKitFiles(into directory: URL) { + let fileManager = FileManager.default + for folder in Self.kitFolders { + let source = documentsDirectory.appendingPathComponent(folder, isDirectory: true) + guard let files = try? fileManager.contentsOfDirectory( + at: source, + includingPropertiesForKeys: [.isRegularFileKey] + ) else { continue } + for file in files where (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true { + do { + try fileManager.copyItem( + at: file, + to: directory.appendingPathComponent("Kit-\(folder)-\(file.lastPathComponent)") + ) + } catch { + warning(.deviceManager, "Could not copy kit log \(file.lastPathComponent): \(error)") + } + } + } + } } diff --git a/Trio/Sources/Logger/Export/LogShareAction.swift b/Trio/Sources/Logger/Export/LogShareAction.swift index be33d35ee..934490444 100644 --- a/Trio/Sources/Logger/Export/LogShareAction.swift +++ b/Trio/Sources/Logger/Export/LogShareAction.swift @@ -1,7 +1,45 @@ import Foundation +import UIKit enum LogShareAction { - static func archive(named _: String, contents _: @escaping (URL) async throws -> Void) async throws -> URL { - throw CocoaError(.featureUnsupported) + @MainActor private 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 + } + } + + static func archive(named name: String, contents: @escaping (URL) async throws -> Void) async throws -> URL { + let backgroundTask = await BackgroundTask(name: name) + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + // The zip's top-level folder takes the content directory's name + let directory = root.appendingPathComponent(name, isDirectory: true) + + do { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + try await contents(directory) + let archive = try DiagnosticArchiver.zip(directory, name: name) + try? fileManager.removeItem(at: root) + await backgroundTask.end() + return archive + } catch { + try? fileManager.removeItem(at: root) + await backgroundTask.end() + throw error + } + } + + static func discard(_ url: URL) { + try? FileManager.default.removeItem(at: url) } } diff --git a/Trio/Sources/Logger/Export/OSLogCapture.swift b/Trio/Sources/Logger/Export/OSLogCapture.swift index a96378b15..5bd595ff4 100644 --- a/Trio/Sources/Logger/Export/OSLogCapture.swift +++ b/Trio/Sources/Logger/Export/OSLogCapture.swift @@ -1,3 +1,62 @@ +import Darwin import Foundation +import OSLog -enum OSLogCapture {} +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) + }() + + static func write(subsystems: [String], since: Date, into directory: URL) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var handles: [String: FileHandle] = [:] + var written: [URL] = [] + + do { + let store = try OSLogStore(scope: .currentProcessIdentifier) + let entries = try store.getEntries( + at: store.position(date: since), + matching: NSPredicate(format: "subsystem IN %@", subsystems) + ) + for case let entry as OSLogEntryLog in entries { + let handle: FileHandle + if let existing = handles[entry.subsystem] { + handle = existing + } else { + let url = directory.appendingPathComponent("OSLog-\(entry.subsystem).txt") + guard FileManager.default.createFile(atPath: url.path, contents: Data("# Current app session only\n".utf8)) + else { throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: url.path]) } + written.append(url) + handle = try FileHandle(forWritingTo: url) + try handle.seekToEnd() + handles[entry.subsystem] = handle + } + let line = + "\(formatter.string(from: entry.date)) \(label(for: entry.level)) [\(entry.category)] \(entry.composedMessage)\n" + try handle.write(contentsOf: Data(line.utf8)) + } + handles.values.forEach { try? $0.close() } + } catch { + handles.values.forEach { try? $0.close() } + written.forEach { try? FileManager.default.removeItem(at: $0) } + warning(.service, "Could not capture os_log entries: \(error)") + } + } + + private static func label(for level: OSLogEntryLog.Level) -> String { + switch level { + case .debug: return "debug" + case .info: return "info" + case .notice: return "notice" + case .error: return "error" + case .fault: return "fault" + default: return "default" + } + } +} diff --git a/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift b/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift index c618eda8c..7dadc1697 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 deviceLogExporter: DeviceLogExporting! @Published var units: GlucoseUnits = .mgdL @Published var shouldDisplayCGMSetupSheet: Bool = false diff --git a/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift b/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift index 8c6b29236..cfea32b36 100644 --- a/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift +++ b/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift @@ -92,6 +92,22 @@ extension CGMSettings { }.listRowBackground(Color.chart) } + if state.cgmCurrent.type == .plugin, + let cgmManager = state.fetchGlucoseManager.cgmManager, + state.cgmCurrent.id == state.fetchGlucoseManager.cgmGlucosePluginId + { + Section { + DeviceLogShareRow( + title: String(localized: "Share CGM Logs"), + footnote: String( + localized: "Includes everything this device reported to Trio in the last 3 days. Low-level Bluetooth traces cover the current app session only." + ), + pluginIdentifier: cgmManager.pluginIdentifier, + exporter: state.deviceLogExporter + ) + }.listRowBackground(Color.chart) + } + SettingInputSection( decimalValue: $decimalPlaceholder, booleanValue: $state.smoothGlucose, diff --git a/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift b/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift index 2a04f422e..d2ad4e94e 100644 --- a/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift +++ b/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift @@ -11,6 +11,7 @@ extension PumpConfig { @Published var pumpState: PumpDisplayState? private(set) var initialSettings: PumpInitialSettings = .default @Injected() var bluetoothManager: BluetoothStateManager! + @Injected() var deviceLogExporter: DeviceLogExporting! override func subscribe() { provider.pumpDisplayState diff --git a/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift b/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift index 5404c786b..b991f848b 100644 --- a/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift +++ b/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift @@ -80,6 +80,19 @@ extension PumpConfig { } } ).listRowBackground(Color.chart) + + if state.pumpState != nil, let pumpManager = state.provider.apsManager.pumpManager { + Section { + DeviceLogShareRow( + title: String(localized: "Share Pump Logs"), + footnote: String( + localized: "Includes everything this device reported to Trio in the last 3 days. Low-level Bluetooth traces cover the current app session only." + ), + pluginIdentifier: pumpManager.pluginIdentifier, + exporter: state.deviceLogExporter + ) + }.listRowBackground(Color.chart) + } } .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme)) .onAppear(perform: configureView) diff --git a/Trio/Sources/Modules/Settings/SettingsStateModel.swift b/Trio/Sources/Modules/Settings/SettingsStateModel.swift index a2d6467d5..e43324b48 100644 --- a/Trio/Sources/Modules/Settings/SettingsStateModel.swift +++ b/Trio/Sources/Modules/Settings/SettingsStateModel.swift @@ -9,6 +9,7 @@ extension Settings { final class StateModel: BaseStateModel { @Injected() private var broadcaster: Broadcaster! @Injected() private var fileManager: FileManager! + @Injected() private var deviceLogExporter: DeviceLogExporting! @Injected() private var nightscoutManager: NightscoutManager! @Injected() var pluginManager: PluginManager! @Injected() var fetchCgmManager: FetchGlucoseManager! @@ -44,18 +45,36 @@ extension Settings { serviceUIType = TidepoolService.self as? ServiceUI.Type } - func logItems() -> [URL] { - var items: [URL] = [] - - if fileManager.fileExists(atPath: SimpleLogReporter.logFile) { - items.append(URL(fileURLWithPath: SimpleLogReporter.logFile)) + func deviceLogsArchive() async throws -> URL { + let now = Date() + let request = DeviceLogExportRequest( + window: DateInterval(start: now.addingTimeInterval(-3 * 24 * 60 * 60), end: now), + managerIdentifier: nil, + includeOSLog: true, + includeKitFiles: true + ) + let exporter = deviceLogExporter + return try await LogShareAction.archive(named: "Trio-DeviceLogs-\(Self.archiveStamp(now))") { directory in + try await exporter?.export(request, into: directory) } + } - if fileManager.fileExists(atPath: SimpleLogReporter.logFilePrev) { - items.append(URL(fileURLWithPath: SimpleLogReporter.logFilePrev)) + func appLogsArchive() async throws -> URL { + let paths = [SimpleLogReporter.logFile, SimpleLogReporter.logFilePrev, SimpleLogReporter.watchLogFile] + let fileManager = self.fileManager ?? .default + return try await LogShareAction.archive(named: "Trio-AppLogs-\(Self.archiveStamp(Date()))") { directory in + for path in paths where fileManager.fileExists(atPath: path) { + let source = URL(fileURLWithPath: path) + try fileManager.copyItem(at: source, to: directory.appendingPathComponent(source.lastPathComponent)) + } } + } - return items + private static func archiveStamp(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd-HHmm" + return formatter.string(from: date) } func hideSettingsModal() { diff --git a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift index 214afda1b..f225b5573 100644 --- a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift +++ b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift @@ -19,6 +19,9 @@ extension Settings { @StateObject var state = StateModel() @State private var showShareSheet = false + @State private var logArchive: URL? + @State private var isPreparingLogs = false + @State private var showLogErrorToast = false @State private var searchText: String = "" @State var hintDetent = PresentationDetent.large @State private var hintPayload: HintPayload? @@ -129,6 +132,23 @@ extension Settings { } } + private func shareLogs(_ makeArchive: @escaping () async throws -> URL) { + isPreparingLogs = true + Task { @MainActor in + do { + logArchive = try await makeArchive() + isPreparingLogs = false + showShareSheet = true + } catch { + isPreparingLogs = false + warning(.service, "Could not prepare logs for sharing: \(error)") + withAnimation { showLogErrorToast = true } + try? await Task.sleep(nanoseconds: 2_000_000_000) + withAnimation { showLogErrorToast = false } + } + } + } + private func copyVersionInfo(_ text: String) { UIPasteboard.general.string = text UINotificationFeedbackGenerator().notificationOccurred(.success) @@ -283,18 +303,24 @@ extension Settings { Section( header: Text("Support & Community"), content: { - Button { - showShareSheet.toggle() + Menu { + Button("Device logs") { shareLogs(state.deviceLogsArchive) } + Button("App logs") { shareLogs(state.appLogsArchive) } } label: { HStack { Text("Share Logs") .foregroundColor(.primary) Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.footnote) + if isPreparingLogs { + ProgressView() + } else { + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.footnote) + } } } + .disabled(isPreparingLogs) .frame(maxWidth: .infinity, alignment: .leading) Button { @@ -440,6 +466,16 @@ extension Settings { .padding(.bottom, 32) .transition(.move(edge: .bottom).combined(with: .opacity)) } + if showLogErrorToast { + Label("Could not prepare logs", systemImage: "exclamationmark.triangle.fill") + .font(.footnote.weight(.semibold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .padding(.bottom, 32) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } } .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme)) .sheet(item: $hintPayload) { payload in @@ -451,8 +487,13 @@ extension Settings { sheetTitle: String(localized: "Help", comment: "Help sheet title") ) } - .sheet(isPresented: $showShareSheet) { - ShareSheet(activityItems: state.logItems()) + .sheet(isPresented: $showShareSheet, onDismiss: { + if let logArchive { + LogShareAction.discard(logArchive) + } + logArchive = nil + }) { + ShareSheet(activityItems: logArchive.map { [$0] } ?? []) } .onAppear(perform: configureView) .task { diff --git a/Trio/Sources/Views/DeviceLogShareRow.swift b/Trio/Sources/Views/DeviceLogShareRow.swift index f931fb468..9e404e1dd 100644 --- a/Trio/Sources/Views/DeviceLogShareRow.swift +++ b/Trio/Sources/Views/DeviceLogShareRow.swift @@ -1,7 +1,103 @@ import SwiftUI struct DeviceLogShareRow: View { + let title: String + let footnote: String + let pluginIdentifier: String + let exporter: DeviceLogExporting + + @State private var isExporting = false + @State private var archiveURL: URL? + @State private var showShareSheet = false + @State private var showExportError = false + @State private var exportErrorMessage = "" + + static let window: TimeInterval = 3 * 24 * 60 * 60 + + static func request(for pluginIdentifier: String, endingAt date: Date) -> DeviceLogExportRequest { + DeviceLogExportRequest( + window: DateInterval(start: date.addingTimeInterval(-window), end: date), + managerIdentifier: pluginIdentifier, + includeOSLog: true, + includeKitFiles: true + ) + } + + private static func archiveName(for pluginIdentifier: String, at date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd-HHmm" + return "Trio-\(pluginIdentifier)-\(formatter.string(from: date))" + } + var body: some View { - EmptyView() + VStack(alignment: .leading) { + Button { + share() + } label: { + HStack { + Text(title) + .foregroundColor(.primary) + Spacer() + if isExporting { + ProgressView() + } else { + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.footnote) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .disabled(isExporting) + + Text(footnote) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + } + .sheet(isPresented: $showShareSheet, onDismiss: removeArchive) { + if let archiveURL { + ShareSheet(activityItems: [archiveURL]) + } + } + .alert("Export Error", isPresented: $showExportError) { + Button("OK", role: .cancel) {} + } message: { + Text(exportErrorMessage) + } + } + + private func share() { + guard !isExporting else { return } + isExporting = true + let now = Date() + let request = Self.request(for: pluginIdentifier, endingAt: now) + let name = Self.archiveName(for: pluginIdentifier, at: now) + let exporter = self.exporter + + Task { @MainActor in + defer { isExporting = false } + do { + archiveURL = try await LogShareAction.archive(named: name) { directory in + try await exporter.export(request, into: directory) + } + showShareSheet = true + } catch { + exportErrorMessage = error.localizedDescription + showExportError = true + } + } + } + + private func removeArchive() { + guard let archiveURL else { return } + self.archiveURL = nil + Task.detached(priority: .utility) { + try? FileManager.default.removeItem(at: archiveURL) + } } } diff --git a/TrioTests/Logging/DeviceLogExporterTests.swift b/TrioTests/Logging/DeviceLogExporterTests.swift index 4a56b570f..2d9fa1210 100644 --- a/TrioTests/Logging/DeviceLogExporterTests.swift +++ b/TrioTests/Logging/DeviceLogExporterTests.swift @@ -1,6 +1,182 @@ import Foundation +import LoopKit import Testing @testable import Trio -@Suite("Device Log Exporter Tests") struct DeviceLogExporterTests {} +@Suite("Device Log Exporter Tests", .serialized) struct DeviceLogExporterTests { + private class StubManager: DeviceManager { + class var pluginIdentifier: String { "com.example.pump" } + var localizedTitle: String { "Example Pump" } + var debugDescription: String { "## StubManager\nstate: synthetic" } + 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 struct Fixture { + let directory: URL + let output: URL + let store: DeviceLogStore + let exporter: DeviceLogExporter + } + + private static func makeFixture(managers: [DeviceManager] = [StubManager(), StubCGMManager()]) throws -> Fixture { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let output = directory.appendingPathComponent("export", isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + let store = DeviceLogStore( + storageURL: directory.appendingPathComponent("logs/DeviceLog.sqlite"), + isProtectedDataAvailable: { true } + ) + let exporter = DeviceLogExporter( + store: store, + documentsDirectory: directory.appendingPathComponent("Documents", isDirectory: true), + managers: { managers } + ) + return Fixture(directory: directory, output: output, store: store, exporter: exporter) + } + + 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 seed(_ store: DeviceLogStore) async { + await log(store, manager: "com.example.pump", device: "device-1", message: "pump one") + await log(store, manager: "com.example.cgm", device: nil, message: "cgm one") + await log(store, manager: "com.example.pump", device: "device-1", message: "pump two") + await log(store, manager: "com.example.orphan", device: "device-2", message: "orphan one") + } + + private static var recentWindow: DateInterval { + DateInterval(start: Date().addingTimeInterval(-3 * 24 * 60 * 60), end: Date().addingTimeInterval(60)) + } + + private static func request( + window: DateInterval = recentWindow, + managerIdentifier: String? = nil, + includeKitFiles: Bool = false + ) -> DeviceLogExportRequest { + DeviceLogExportRequest( + window: window, + managerIdentifier: managerIdentifier, + includeOSLog: false, + includeKitFiles: includeKitFiles + ) + } + + private static func files(in directory: URL) throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: directory.path).sorted() + } + + private static func entryLines(_ url: URL) throws -> [String] { + try String(contentsOf: url, encoding: .utf8) + .split(separator: "\n") + .map(String.init) + .filter { $0.hasPrefix("* ") } + } + + @Test("Exporting all writes one file per identifier") func exportsAll() async throws { + let fixture = try Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await Self.seed(fixture.store) + + try await fixture.exporter.export(Self.request(), into: fixture.output) + + #expect(try Self.files(in: fixture.output) == [ + "DeviceLog-com.example.cgm.txt", + "DeviceLog-com.example.orphan.txt", + "DeviceLog-com.example.pump.txt" + ]) + + let pumpURL = fixture.output.appendingPathComponent("DeviceLog-com.example.pump.txt") + let pump = try String(contentsOf: pumpURL, encoding: .utf8) + #expect(pump.hasPrefix("# Example Pump (com.example.pump)\n\n```\n## StubManager\nstate: synthetic\n```\n")) + + let pumpLines = try Self.entryLines(pumpURL) + #expect(pumpLines.count == 2) + #expect(pumpLines.allSatisfy { + $0.range( + of: #"^\* \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z com\.example\.pump device-1 send pump (one|two)$"#, + options: .regularExpression + ) != nil + }) + #expect(pumpLines.first?.hasSuffix("pump one") == true) + + let cgmLines = try Self.entryLines(fixture.output.appendingPathComponent("DeviceLog-com.example.cgm.txt")) + #expect(cgmLines.count == 1) + #expect(cgmLines.first?.hasSuffix(" com.example.cgm - send cgm one") == true) + + let orphanURL = fixture.output.appendingPathComponent("DeviceLog-com.example.orphan.txt") + #expect(try String(contentsOf: orphanURL, encoding: .utf8).hasPrefix("# com.example.orphan\n")) + #expect(try Self.entryLines(orphanURL).count == 1) + } + + @Test("Exporting one identifier writes only its file") func exportsOne() async throws { + let fixture = try Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await Self.seed(fixture.store) + + try await fixture.exporter.export(Self.request(managerIdentifier: "com.example.cgm"), into: fixture.output) + + #expect(try Self.files(in: fixture.output) == ["DeviceLog-com.example.cgm.txt"]) + #expect(try Self.entryLines(fixture.output.appendingPathComponent("DeviceLog-com.example.cgm.txt")).count == 1) + } + + @Test("Stored entries export without a live manager") func exportsWithoutLiveManager() async throws { + let fixture = try Self.makeFixture(managers: []) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await Self.seed(fixture.store) + + try await fixture.exporter.export(Self.request(managerIdentifier: "com.example.pump"), into: fixture.output) + + let url = fixture.output.appendingPathComponent("DeviceLog-com.example.pump.txt") + #expect(try Self.files(in: fixture.output) == ["DeviceLog-com.example.pump.txt"]) + #expect(try String(contentsOf: url, encoding: .utf8).hasPrefix("# com.example.pump\n")) + #expect(try Self.entryLines(url).count == 2) + } + + @Test("Entries outside the window are skipped") func filtersWindow() async throws { + let fixture = try Self.makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await Self.seed(fixture.store) + + let past = DateInterval(start: Date().addingTimeInterval(-2 * 24 * 60 * 60), end: Date().addingTimeInterval(-60 * 60)) + try await fixture.exporter.export(Self.request(window: past), into: fixture.output) + + #expect(try Self.files(in: fixture.output) == ["DeviceLog-com.example.cgm.txt", "DeviceLog-com.example.pump.txt"]) + #expect(try Self.entryLines(fixture.output.appendingPathComponent("DeviceLog-com.example.pump.txt")).isEmpty) + #expect(try Self.entryLines(fixture.output.appendingPathComponent("DeviceLog-com.example.cgm.txt")).isEmpty) + } + + @Test("Kit files are copied with a prefix") func copiesKitFiles() async throws { + let fixture = try Self.makeFixture(managers: []) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let kitDirectory = fixture.directory.appendingPathComponent("Documents/danakit", isDirectory: true) + try FileManager.default.createDirectory(at: kitDirectory, withIntermediateDirectories: true) + try Data("synthetic".utf8).write(to: kitDirectory.appendingPathComponent("log.txt")) + + try await fixture.exporter.export(Self.request(includeKitFiles: true), into: fixture.output) + + #expect(try Self.files(in: fixture.output) == ["Kit-danakit-log.txt"]) + } +} diff --git a/TrioTests/Logging/DeviceLogShareRequestTests.swift b/TrioTests/Logging/DeviceLogShareRequestTests.swift index e15f33b38..e11a0207b 100644 --- a/TrioTests/Logging/DeviceLogShareRequestTests.swift +++ b/TrioTests/Logging/DeviceLogShareRequestTests.swift @@ -3,4 +3,16 @@ import Testing @testable import Trio -@Suite("Device Log Share Request Tests") struct DeviceLogShareRequestTests {} +@Suite("Device Log Share Request Tests") struct DeviceLogShareRequestTests { + @Test("Request targets the manager over the last 3 days with OSLog and kit files") func requestForManager() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + + let request = DeviceLogShareRow.request(for: "TestPumpManager", endingAt: now) + + #expect(request.managerIdentifier == "TestPumpManager") + #expect(request.window.end == now) + #expect(request.window.duration == 3 * 24 * 60 * 60) + #expect(request.includeOSLog) + #expect(request.includeKitFiles) + } +} From 854c565a107c206639574f3d156da872e1117893 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:17:24 +0200 Subject: [PATCH 09/48] Store the app log in Core Data --- Trio Watch App Extension/WatchLogger.swift | 11 +- Trio.xcodeproj/project.pbxproj | 8 - Trio/Sources/Application/TrioApp.swift | 5 +- .../Localizations/Main/Localizable.xcstrings | 21 + .../Logger/Core/LegacyFileLogSink.swift | 105 ----- .../IssueReporter/SimpleLogReporter.swift | 43 -- Trio/Sources/Logger/Store/AppLogStore.swift | 394 +++++++++++++++++- .../Logger/Store/AppLogTextExporter.swift | 97 ++++- .../AppDiagnosticsStateModel.swift | 12 + .../View/AppDiagnosticsRootView.swift | 17 + .../Modules/Settings/SettingsStateModel.swift | 12 +- .../WatchManager/AppleWatchManager.swift | 4 +- TrioTests/Logging/AppLogStoreTests.swift | 179 +++++++- .../Logging/LegacyFileLogSinkTests.swift | 37 -- TrioTests/Logging/WatchLogIngestTests.swift | 26 +- 15 files changed, 758 insertions(+), 213 deletions(-) delete mode 100644 Trio/Sources/Logger/Core/LegacyFileLogSink.swift delete mode 100644 TrioTests/Logging/LegacyFileLogSinkTests.swift diff --git a/Trio Watch App Extension/WatchLogger.swift b/Trio Watch App Extension/WatchLogger.swift index ff6d87f13..666e26271 100644 --- a/Trio Watch App Extension/WatchLogger.swift +++ b/Trio Watch App Extension/WatchLogger.swift @@ -68,7 +68,8 @@ actor WatchLogger { return } - let payload: [String: Any] = ["watchLogs": logs.joined(separator: "\n")] + let batch = logs + let payload: [String: Any] = ["watchLogs": batch.joined(separator: "\n")] if session.activationState != .activated { session.activate() @@ -77,7 +78,7 @@ actor WatchLogger { if session.isReachable { session.sendMessage(payload, replyHandler: nil) { _ in Task { - await self.persistLogsLocally() + await self.persistLogsLocally(batch) } } } else { @@ -88,7 +89,7 @@ actor WatchLogger { logs.removeAll() } - func persistLogsLocally() async { + func persistLogsLocally(_ entries: [String]? = nil) async { let logDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! .appendingPathComponent("logs", isDirectory: true) @@ -107,7 +108,7 @@ actor WatchLogger { FileManager.default.createFile(atPath: logFile.path, contents: nil, attributes: [.creationDate: startOfDay]) } - let fullLog = logs.joined(separator: "\n") + "\n" + let fullLog = (entries ?? logs).joined(separator: "\n") + "\n" if let data = fullLog.data(using: .utf8) { if let handle = try? FileHandle(forWritingTo: logFile) { try? handle.seekToEnd() @@ -138,7 +139,7 @@ actor WatchLogger { if session.isReachable { session.sendMessage(payload, replyHandler: nil) { _ in Task { - await self.persistLogsLocally() + await self.persistLogsLocally(logString.split(whereSeparator: \.isNewline).map(String.init)) } } try? FileManager.default.removeItem(at: logFile) diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 1e1cf1209..a7d92e19f 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ 03B15F4F3077CD6100DFEBE4 /* SyaiKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 041D1E995A6AE92E9289DC49 /* TreatmentsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D1A7CA8C10C4403D4BBFA7 /* TreatmentsDataFlow.swift */; }; 06B768620A4F3877551BF0B8 /* LoopTimingSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DE70925909EFD810B65B94 /* LoopTimingSection.swift */; }; - 0847A90799FD02694AE3C447 /* LegacyFileLogSinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025C71818E3D84E0C32F60B3 /* LegacyFileLogSinkTests.swift */; }; 0D1EAF43EA72BAE07D16FD25 /* NightscoutSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A818A5FB069B0396BED1EC46 /* NightscoutSection.swift */; }; 0D9A5E34A899219C5C4CDFAF /* HistoryStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9455FA2D92E77A6C4AFED8A3 /* HistoryStateModel.swift */; }; 10DAA318F47843A4B894173D /* ContainerResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98546920BB7247B0AC73449A /* ContainerResolutionTests.swift */; }; @@ -160,7 +159,6 @@ 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */; }; 60886ABC93CDB0D0EF6F7660 /* DeviceLogExporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0AE7DD84FC1252EAADC35F1 /* DeviceLogExporting.swift */; }; 6ED6CA7C8BB78F440C2DDE04 /* BuildSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39C1F3299C2A3903B601D11 /* BuildSection.swift */; }; - 7539F9BBEDB0DC975CE0305E /* LegacyFileLogSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91BFCE121E9F8826CE05BDE /* LegacyFileLogSink.swift */; }; 7BA97B74F25D6A1F5D853EFB /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F602845F4994CA0A57286EE6 /* ShareSheet.swift */; }; 7D9C485831D451703B20F283 /* DeviceLogShareRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FABAFA208B56BAA140521720 /* DeviceLogShareRequestTests.swift */; }; 8C9B2B65B1FF556ADF8915DF /* WatchSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AA5756BADDE99F52317A6D /* WatchSection.swift */; }; @@ -1253,7 +1251,6 @@ /* Begin PBXFileReference section */ 00DE70925909EFD810B65B94 /* LoopTimingSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopTimingSection.swift; sourceTree = ""; }; - 025C71818E3D84E0C32F60B3 /* LegacyFileLogSinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyFileLogSinkTests.swift; sourceTree = ""; }; 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0A1E8522E7684C6AA742F6E4 /* HomeStateModel+QuickPickTreatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeStateModel+QuickPickTreatments.swift"; sourceTree = ""; }; @@ -2345,7 +2342,6 @@ F90692D0274B99B60037068D /* HealthKitProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitProvider.swift; sourceTree = ""; }; F90692D2274B9A130037068D /* AppleHealthKitRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleHealthKitRootView.swift; sourceTree = ""; }; F90692D5274B9A450037068D /* HealthKitStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitStateModel.swift; sourceTree = ""; }; - F91BFCE121E9F8826CE05BDE /* LegacyFileLogSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyFileLogSink.swift; sourceTree = ""; }; FAB1E00000000000000000B1 /* ParityHarness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityHarness.swift; sourceTree = ""; }; FAB1E00000000000000000B2 /* ParityInputs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityInputs.swift; sourceTree = ""; }; FAB1E00000000000000000B3 /* ParityScenarios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityScenarios.swift; sourceTree = ""; }; @@ -3999,7 +3995,6 @@ children = ( 69EF64E9534E4822199EA279 /* TrioLogger.swift */, E9C171A8FC35B730F1FFF25F /* InMemoryLogSink.swift */, - F91BFCE121E9F8826CE05BDE /* LegacyFileLogSink.swift */, 24AF0AA0328EA5E4016BD4D6 /* LaunchID.swift */, ); path = Core; @@ -4023,7 +4018,6 @@ 161D44BBD9BB82CE0AF6B4EC /* DeviceLogStoreTests.swift */, 6F42FDD4C077138ADA03C3CA /* DeviceLogExporterTests.swift */, C7BC1040D2F0BD84E0718784 /* TrioLoggerTests.swift */, - 025C71818E3D84E0C32F60B3 /* LegacyFileLogSinkTests.swift */, 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */, 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */, B8BA4CCF9E04B65D2624F103 /* DiagnosticReportGeneratorTests.swift */, @@ -6496,7 +6490,6 @@ F60FCBDC5FB1457995D444EC /* KnownLogSubsystems.swift in Sources */, E0F5C5534791F6BB9A237891 /* TrioLogger.swift in Sources */, 931AD2C542093B9D1A342065 /* InMemoryLogSink.swift in Sources */, - 7539F9BBEDB0DC975CE0305E /* LegacyFileLogSink.swift in Sources */, 27DE1FE3427339F87A4D1892 /* LaunchID.swift in Sources */, 10C13662926D99DC9EDB0990 /* DeviceLogStore.swift in Sources */, 0F2B374F0AED7ECBAD5716B1 /* DeviceLogExporter.swift in Sources */, @@ -6638,7 +6631,6 @@ 2D5DFD6C90037128EA3DF5A9 /* DeviceLogStoreTests.swift in Sources */, C2E1829091B5A504901C6AB6 /* DeviceLogExporterTests.swift in Sources */, BD14CA4C88D904242178280A /* TrioLoggerTests.swift in Sources */, - 0847A90799FD02694AE3C447 /* LegacyFileLogSinkTests.swift in Sources */, 91AE5F35FC765DFE4C1A2589 /* AppLogStoreTests.swift in Sources */, FC83BDFB7C330BFD6A4DECFC /* WatchLogIngestTests.swift in Sources */, BDAEE0006F95672099C25715 /* DiagnosticReportGeneratorTests.swift in Sources */, diff --git a/Trio/Sources/Application/TrioApp.swift b/Trio/Sources/Application/TrioApp.swift index 7c7989db4..9e69aedc8 100644 --- a/Trio/Sources/Application/TrioApp.swift +++ b/Trio/Sources/Application/TrioApp.swift @@ -67,6 +67,9 @@ extension Notification.Name { private func loadServices() { resolver.resolve(AppearanceManager.self)!.setupGlobalAppearance() + if let settingsManager = resolver.resolve(SettingsManager.self), let broadcaster = resolver.resolve(Broadcaster.self) { + AppLogStore.shared.observeSettings(of: settingsManager, broadcaster: broadcaster) + } _ = resolver.resolve(DeviceDataManager.self)! _ = resolver.resolve(APSManager.self)! _ = resolver.resolve(FetchGlucoseManager.self)! @@ -128,7 +131,7 @@ extension Notification.Name { } init() { - TrioLogger.install(LegacyFileLogSink()) + TrioLogger.install(AppLogStore.shared) FileProtectionFixer.fixFlagFileProtectionForPropertyPersistentFlags() // TODO: ‼️ REMOVE ME BEFORE PUBLIC BETA / RELEASE let notificationCenter = Foundation.NotificationCenter.default diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index b38448954..bd5c9e69d 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1088,6 +1088,27 @@ } } } + }, + "1 day" : { + + }, + "3 days" : { + + }, + "Also keep detailed debug messages in the app log." : { + + }, + "How long Trio keeps its app log on this device." : { + + }, + "Log Retention" : { + + }, + "Logging" : { + + }, + "Verbose Logging" : { + }, "Choose the preferred lower threshold for Time in Range statistics." : { "comment" : "Short description of the lower range threshold setting.", diff --git a/Trio/Sources/Logger/Core/LegacyFileLogSink.swift b/Trio/Sources/Logger/Core/LegacyFileLogSink.swift deleted file mode 100644 index 5b714a473..000000000 --- a/Trio/Sources/Logger/Core/LegacyFileLogSink.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation - -final class LegacyFileLogSink: LogSink, @unchecked Sendable { - static var defaultDirectory: URL { - (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory) - .appendingPathComponent("logs", isDirectory: true) - } - - let directory: URL - let fileURL: URL - let previousFileURL: URL - - private let queue = DispatchQueue(label: "LegacyFileLogSink.queue", qos: .utility) - private let fileManager = FileManager.default - private let calendar = Calendar.current - private let formatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - return formatter - }() - - // Only touched on `queue`. - private var handle: FileHandle? - private var handleDay: Date? - - init(directory: URL = LegacyFileLogSink.defaultDirectory) { - self.directory = directory - fileURL = directory.appendingPathComponent("log.txt") - previousFileURL = directory.appendingPathComponent("log_prev.txt") - } - - deinit { - try? handle?.close() - } - - func accept(_ record: LogRecord) { - queue.async { self.write(record) } - } - - func flush() async { - await withCheckedContinuation { continuation in - queue.async { continuation.resume() } - } - } - - func records(in _: DateInterval, minimumLevel _: LogLevel) async -> [LogRecord] { - [] - } - - private func write(_ record: LogRecord) { - let startOfDay = calendar.startOfDay(for: record.timestamp) - if handleDay != startOfDay { - try? handle?.close() - handle = openFile(startOfDay: startOfDay) - handleDay = handle == nil ? nil : startOfDay - } - guard let handle else { return } - - let line = - "\(formatter.string(from: record.timestamp)) [\(record.category)] \(record.file) - \(record.function) - \(record.line) - \(record.level.linePrefix)\(record.message)\n" - do { - try handle.write(contentsOf: Data(line.utf8)) - } catch { - try? handle.close() - self.handle = nil - handleDay = nil - } - } - - private func openFile(startOfDay: Date) -> FileHandle? { - if !fileManager.fileExists(atPath: directory.path) { - try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - } - - if fileManager.fileExists(atPath: fileURL.path) { - if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path), - let creationDate = attributes[.creationDate] as? Date, creationDate < startOfDay - { - try? fileManager.removeItem(at: previousFileURL) - try? fileManager.moveItem(at: fileURL, to: previousFileURL) - fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: [.creationDate: startOfDay]) - } - } else { - fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: [.creationDate: startOfDay]) - } - - guard let handle = try? FileHandle(forWritingTo: fileURL) else { return nil } - _ = try? handle.seekToEnd() - return handle - } -} - -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: " - } - } -} diff --git a/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift b/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift index 3067bbbc6..8612f3561 100644 --- a/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift +++ b/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift @@ -1,5 +1,4 @@ import Foundation -import System enum SimpleLogReporter { static var logFile: String { @@ -29,46 +28,4 @@ extension SimpleLogReporter { static var watchLogFilePrev: String { getDocumentsDirectory().appendingPathComponent("logs/watch_log_prev.txt").path } - - static func appendToWatchLog(_ logContent: String) { - let fileManager = FileManager.default - let logDir = getDocumentsDirectory().appendingPathComponent("logs") - let logFile = URL(fileURLWithPath: watchLogFile) - let prevLogFile = URL(fileURLWithPath: watchLogFilePrev) - - let now = Date() - let startOfDay = Calendar.current.startOfDay(for: now) - - // Create logs directory if needed - if !fileManager.fileExists(atPath: logDir.path) { - try? fileManager.createDirectory(at: logDir, withIntermediateDirectories: true) - } - - // Rotate if needed - if fileManager.fileExists(atPath: logFile.path), - let attributes = try? fileManager.attributesOfItem(atPath: logFile.path), - let creationDate = attributes[.creationDate] as? Date, - creationDate < startOfDay - { - try? fileManager.removeItem(at: prevLogFile) - try? fileManager.moveItem(at: logFile, to: prevLogFile) - fileManager.createFile(atPath: logFile.path, contents: nil, attributes: [.creationDate: startOfDay]) - } - - if let data = (logContent + "\n").data(using: .utf8) { - try? data.append(fileURL: logFile) - } - } -} - -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) } - } } diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index f2ebc763d..82da25d65 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -1,3 +1,395 @@ +import CoreData import Foundation +import os +import UIKit -actor AppLogStore {} +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 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 didMoveStoreAside = 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() + } + } + } + + func flush() async { + await withCheckedContinuation { continuation in + queue.async { + self.flushPending() + continuation.resume() + } + } + } + + func records(in window: DateInterval, minimumLevel: LogLevel) async -> [LogRecord] { + await withCheckedContinuation { continuation in + queue.async { + 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() + } + } + + func observeSettings(of settingsManager: SettingsManager, broadcaster: Broadcaster) { + apply(LogRetentionPolicy(settings: settingsManager.settings)) + broadcaster.register(SettingsObserver.self, observer: self) + } + + 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 + } + if !batch.isEmpty { + insert(batch, into: context) + } + if let lastPurge, Date().timeIntervalSince(lastPurge) < Self.purgeInterval { return } + purge() + } + + private func insert(_ batch: [LogRecord], into context: NSManagedObjectContext) { + 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": record.category, + "message": record.message, + "file": record.file, + "function": record.function, + "line": Int32(clamping: record.line), + "launchID": record.launchID + ]) + return false + }) + context.performAndWait { + do { + try context.execute(request) + } catch { + Logger.Category.coreData.osLogger.error("App log insert failed: \(String(describing: error), privacy: .public)") + } + } + } + + 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.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)] + return context.performAndWait { + let rows = (try? context.fetch(request)) ?? [] + return rows.compactMap { row in + guard let timestamp = row["timestamp"] as? Date, + let level = (row["level"] as? Int16).flatMap(LogLevel.init(rawValue:)) + else { return nil } + return 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() + ) + } + } + } + + private func purge() { + guard let context else { return } + lastPurge = Date() + let maxAge = policy.maxAge + let maxRows = policy.maxRows + context.performAndWait { + deleteEntries(before: Date().addingTimeInterval(-maxAge), in: context) + + 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) + do { + try context.execute(NSBatchDeleteRequest(fetchRequest: request)) + } catch { + Logger.Category.coreData.osLogger.error("App log purge failed: \(String(describing: error), privacy: .public)") + } + } + + private func openIfNeeded() { + guard container == nil, !isCheckingProtectedData else { 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) + } + } + } + + private func recover(protectedDataAvailable: Bool) { + guard container == nil, protectedDataAvailable, !didMoveStoreAside else { return } + didMoveStoreAside = true + moveStoreAside() + if let container = loadContainer(storageURL) { + open(container) + } else { + Logger.Category.coreData.osLogger.error("App log store unavailable, keeping records in memory") + } + } + + private func open(_ container: NSPersistentContainer) { + self.container = container + let context = container.newBackgroundContext() + context.undoManager = nil + self.context = context + deleteLegacyFiles() + 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.coreData.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 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 } + guard loadError == nil else { + let coordinator = container.persistentStoreCoordinator + for store in coordinator.persistentStores { + try? coordinator.remove(store) + } + return nil + } + return container + } +} + +extension AppLogStore: SettingsObserver { + func settingsDidChange(_ settings: TrioSettings) { + apply(LogRetentionPolicy(settings: settings)) + } +} + +extension LogRetentionPolicy { + init(settings: TrioSettings) { + self.init( + maxAge: TimeInterval(max(1, settings.logRetentionDays)) * 24 * 60 * 60, + persistsDebug: settings.logVerboseEnabled, + maxRows: LogRetentionPolicy.default.maxRows + ) + } +} + +extension AppLogStore { + private static let watchTimestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + return formatter + }() + + static func watchRecords(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 = watchTimestampFormatter + .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: "Watch", + message: message, + file: "WatchLogger", + function: "", + line: 0, + launchID: LaunchID.current + ) + } + } +} diff --git a/Trio/Sources/Logger/Store/AppLogTextExporter.swift b/Trio/Sources/Logger/Store/AppLogTextExporter.swift index f97239b27..da9d9fdd4 100644 --- a/Trio/Sources/Logger/Store/AppLogTextExporter.swift +++ b/Trio/Sources/Logger/Store/AppLogTextExporter.swift @@ -1,3 +1,98 @@ import Foundation -enum AppLogTextExporter {} +enum AppLogTextExporter { + static let fileName = "AppLog.txt" + static let legacyFileNames = ["log.txt", "log_prev.txt", "watch_log.txt"] + private static let sliceDuration: TimeInterval = 6 * 60 * 60 + + private static let timestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func line(for record: LogRecord) -> String { + "\(timestampFormatter.string(from: record.timestamp)) [\(record.level.label)] [\(record.category)] \(record.file):\(record.line) \(record.function) - \(record.message)\n" + } + + static func write(records: [LogRecord], to url: URL) throws { + let handle = try createFile(at: url) + defer { try? handle.close() } + try append(records, to: handle) + } + + static func export( + from store: AppLogStore, + window: DateInterval, + minimumLevel: LogLevel = .debug, + into directory: URL + ) async throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let handle = try createFile(at: directory.appendingPathComponent(fileName)) + defer { try? handle.close() } + + var start = window.start + while true { + let end = min(start.addingTimeInterval(sliceDuration), window.end) + let isLast = end >= window.end + let records = await store.records(in: DateInterval(start: start, end: end), minimumLevel: minimumLevel) + // Slice ends are inclusive; leave boundary records to the next slice + try append(isLast ? records : records.filter { $0.timestamp < end }, to: handle) + if isLast { break } + start = end + } + + for name in legacyFileNames { + let source = store.directory.appendingPathComponent(name) + guard FileManager.default.fileExists(atPath: source.path) else { continue } + let destination = directory.appendingPathComponent("Legacy-\(name)") + try? FileManager.default.removeItem(at: destination) + try FileManager.default.copyItem(at: source, to: destination) + } + } + + private static func createFile(at url: URL) throws -> FileHandle { + guard FileManager.default.createFile(atPath: url.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: url.path]) + } + return try FileHandle(forWritingTo: url) + } + + private static func append(_ records: [LogRecord], to handle: FileHandle) throws { + var buffer = Data() + for record in records { + buffer.append(contentsOf: line(for: record).utf8) + if buffer.count >= 64 * 1024 { + try handle.write(contentsOf: buffer) + buffer.removeAll(keepingCapacity: true) + } + } + if !buffer.isEmpty { + try handle.write(contentsOf: buffer) + } + } +} + +extension LogLevel { + var label: String { + switch self { + case .debug: return "DEBUG" + case .info: return "INFO" + case .notice: return "NOTICE" + case .warning: return "WARNING" + case .error: return "ERROR" + case .fault: return "FAULT" + } + } + + 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: " + } + } +} diff --git a/Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift b/Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift index 92f9e3020..df3e32301 100644 --- a/Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift +++ b/Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift @@ -7,8 +7,20 @@ extension AppDiagnostics { var diagnosticsSharingOption: DiagnosticsSharingOption = .full + // MARK: - Logging + + var logRetentionDays = 7 { + didSet { settingsManager.settings.logRetentionDays = logRetentionDays } + } + + var logVerboseEnabled = true { + didSet { settingsManager.settings.logVerboseEnabled = logVerboseEnabled } + } + override func subscribe() { loadDiagnostics() + logRetentionDays = settingsManager.settings.logRetentionDays + logVerboseEnabled = settingsManager.settings.logVerboseEnabled } /// Derives the 3-state option from the two underlying flags. Both diff --git a/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift b/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift index 2a810568c..d49ec6e62 100644 --- a/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift +++ b/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift @@ -64,6 +64,23 @@ extension AppDiagnostics { NavigationLink("Privacy details") { TelemetryPrivacyView() } }.listRowBackground(Color.chart) + Section( + header: Text("Logging"), + footer: Text("How long Trio keeps its app log on this device.") + ) { + Picker("Log Retention", selection: $state.logRetentionDays) { + Text("1 day").tag(1) + Text("3 days").tag(3) + Text("7 days").tag(7) + } + }.listRowBackground(Color.chart) + + Section( + footer: Text("Also keep detailed debug messages in the app log.") + ) { + Toggle("Verbose Logging", isOn: $state.logVerboseEnabled) + }.listRowBackground(Color.chart) + Section { VStack(alignment: .leading, spacing: 8) { Text("Why does Trio collect this data?").bold() diff --git a/Trio/Sources/Modules/Settings/SettingsStateModel.swift b/Trio/Sources/Modules/Settings/SettingsStateModel.swift index e43324b48..3b97bf7e9 100644 --- a/Trio/Sources/Modules/Settings/SettingsStateModel.swift +++ b/Trio/Sources/Modules/Settings/SettingsStateModel.swift @@ -8,7 +8,6 @@ import TidepoolServiceKit extension Settings { final class StateModel: BaseStateModel { @Injected() private var broadcaster: Broadcaster! - @Injected() private var fileManager: FileManager! @Injected() private var deviceLogExporter: DeviceLogExporting! @Injected() private var nightscoutManager: NightscoutManager! @Injected() var pluginManager: PluginManager! @@ -60,13 +59,10 @@ extension Settings { } func appLogsArchive() async throws -> URL { - let paths = [SimpleLogReporter.logFile, SimpleLogReporter.logFilePrev, SimpleLogReporter.watchLogFile] - let fileManager = self.fileManager ?? .default - return try await LogShareAction.archive(named: "Trio-AppLogs-\(Self.archiveStamp(Date()))") { directory in - for path in paths where fileManager.fileExists(atPath: path) { - let source = URL(fileURLWithPath: path) - try fileManager.copyItem(at: source, to: directory.appendingPathComponent(source.lastPathComponent)) - } + let now = Date() + let window = DateInterval(start: now.addingTimeInterval(-3 * 24 * 60 * 60), end: now) + return try await LogShareAction.archive(named: "Trio-AppLogs-\(Self.archiveStamp(now))") { directory in + try await AppLogTextExporter.export(from: .shared, window: window, into: directory) } } diff --git a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift index 88d75f711..1efbe8342 100644 --- a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift +++ b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift @@ -645,7 +645,7 @@ 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) + AppLogStore.watchRecords(from: logs).forEach(TrioLogger.sink.accept) } Task { @MainActor [weak self] in @@ -785,7 +785,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { if let logs = userInfo["watchLogs"] as? String { - SimpleLogReporter.appendToWatchLog(logs) + AppLogStore.watchRecords(from: logs).forEach(TrioLogger.sink.accept) } if let snoozeMinutes = userInfo[WatchMessageKeys.snoozeDuration] as? Int { diff --git a/TrioTests/Logging/AppLogStoreTests.swift b/TrioTests/Logging/AppLogStoreTests.swift index b9323381c..4f2b4e4e1 100644 --- a/TrioTests/Logging/AppLogStoreTests.swift +++ b/TrioTests/Logging/AppLogStoreTests.swift @@ -1,6 +1,183 @@ +import CoreData import Foundation import Testing +import UIKit @testable import Trio -@Suite("App Log Store Tests") struct AppLogStoreTests {} +@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 + ) + } + + private static func waitUntil(_ condition: () async -> Bool) async -> Bool { + for _ in 0 ..< 100 { + 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("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("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("Exports records as text lines") func exportsText() 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(-7 * 60 * 60), level: .warning)) + store.accept(Self.record(1, at: now)) + await store.flush() + try Data("legacy\n".utf8).write(to: store.directory.appendingPathComponent("log.txt")) + + let output = directory.appendingPathComponent("export", isDirectory: true) + try await AppLogTextExporter.export( + from: store, + window: DateInterval(start: now.addingTimeInterval(-24 * 60 * 60), end: now), + into: output + ) + + let lines = try String(contentsOf: output.appendingPathComponent(AppLogTextExporter.fileName), encoding: .utf8) + .split(separator: "\n") + #expect(lines.count == 2) + #expect(lines.first?.hasSuffix("[WARNING] [Service] File.swift:0 run() - message 0") == true) + #expect(lines.last?.hasSuffix("[INFO] [Service] File.swift:1 run() - message 1") == true) + #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Legacy-log.txt").path)) + } +} diff --git a/TrioTests/Logging/LegacyFileLogSinkTests.swift b/TrioTests/Logging/LegacyFileLogSinkTests.swift deleted file mode 100644 index c61d93ed1..000000000 --- a/TrioTests/Logging/LegacyFileLogSinkTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation -import Testing - -@testable import Trio - -@Suite("Legacy File Log Sink Tests") struct LegacyFileLogSinkTests { - @Test("Writes lines in order after flush") func testWritesInOrder() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let sink = LegacyFileLogSink(directory: directory) - let timestamp = Date() - for index in 0 ..< 100 { - sink.accept(LogRecord( - timestamp: timestamp, - level: index.isMultiple(of: 2) ? .debug : .warning, - category: "Service", - message: "line \(index)", - file: "File.swift", - function: "run()", - line: UInt(index), - launchID: LaunchID.current - )) - } - await sink.flush() - - let contents = try String(contentsOf: sink.fileURL, encoding: .utf8) - let lines = contents.split(separator: "\n").map(String.init) - #expect(lines.count == 100) - #expect(lines.first?.hasSuffix("[Service] File.swift - run() - 0 - DEV: line 0") == true) - #expect(lines.dropFirst().first?.hasSuffix("[Service] File.swift - run() - 1 - WARN: line 1") == true) - #expect(lines.enumerated().allSatisfy { $0.element.hasSuffix("line \($0.offset)") }) - - let stamp = try #require(lines.first?.split(separator: " ").first) - #expect(stamp.contains(".")) - } -} diff --git a/TrioTests/Logging/WatchLogIngestTests.swift b/TrioTests/Logging/WatchLogIngestTests.swift index 419acd97b..e8426a8cd 100644 --- a/TrioTests/Logging/WatchLogIngestTests.swift +++ b/TrioTests/Logging/WatchLogIngestTests.swift @@ -3,4 +3,28 @@ import Testing @testable import Trio -@Suite("Watch Log Ingest Tests") struct WatchLogIngestTests {} +@Suite("Watch Log Ingest Tests") struct WatchLogIngestTests { + @Test("Each payload line becomes a watch record in order") func parsesLines() { + 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 = AppLogStore.watchRecords(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" && $0.level == .debug && $0.file == "WatchLogger" }) + #expect(records.map(\.timestamp) == [ + Date(timeIntervalSince1970: 1_767_323_045), + received, + Date(timeIntervalSince1970: 1_767_323_046) + ]) + } +} From 2b03e2c2a56d4bec30bfd089fb015e20d981c379 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:34:04 +0200 Subject: [PATCH 10/48] Add diagnostic report with redaction --- Trio/Sources/Assemblies/ServiceAssembly.swift | 1 + .../Localizations/Main/Localizable.xcstrings | 27 ++ .../Report/DiagnosticReportGenerator.swift | 268 +++++++++++++++++- .../Logger/Report/DiagnosticReportView.swift | 125 +++++++- .../Logger/Report/RedactionPatterns.swift | 37 ++- Trio/Sources/Logger/Report/Redactor.swift | 133 ++++++++- .../Report/Sections/AlertsSection.swift | 42 ++- .../Report/Sections/AppLogSection.swift | 74 ++++- .../Logger/Report/Sections/BuildSection.swift | 27 +- .../Sections/DeterminationsSection.swift | 138 ++++++++- .../Report/Sections/DeviceInfoSection.swift | 46 ++- .../Report/Sections/DeviceLogSection.swift | 44 ++- .../Sections/DeviceManagersSection.swift | 44 ++- .../Report/Sections/LoopTimingSection.swift | 59 +++- .../Report/Sections/NightscoutSection.swift | 58 +++- .../Report/Sections/SettingsSection.swift | 61 +++- .../Logger/Report/Sections/WatchSection.swift | 32 ++- .../Modules/Settings/SettingsStateModel.swift | 1 + .../Settings/View/SettingsRootView.swift | 5 + .../Logging/DeterminationsSectionTests.swift | 82 +++++- .../DiagnosticReportGeneratorTests.swift | 72 ++++- TrioTests/Logging/RedactorTests.swift | 82 +++++- 22 files changed, 1440 insertions(+), 18 deletions(-) diff --git a/Trio/Sources/Assemblies/ServiceAssembly.swift b/Trio/Sources/Assemblies/ServiceAssembly.swift index f31a6427d..2f6c02037 100644 --- a/Trio/Sources/Assemblies/ServiceAssembly.swift +++ b/Trio/Sources/Assemblies/ServiceAssembly.swift @@ -38,5 +38,6 @@ final class ServiceAssembly: Assembly { .inObjectScope(.container) container.register(TelemetryAttestor.self) { r in TelemetryAttestor(resolver: r) } .inObjectScope(.container) + container.register(DiagnosticReportGenerator.self) { r in DiagnosticReportGenerator(resolver: r) } } } diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index bd5c9e69d..a497541ee 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -13316,6 +13316,9 @@ } } } + }, + "%lld days" : { + }, "%lld g" : { "localizations" : { @@ -23472,6 +23475,9 @@ } } } + }, + "1 day" : { + }, "1 U and over" : { "comment" : "Name to display for the \"1 U and over\" bolus display threshold option.", @@ -89948,6 +89954,9 @@ } } } + }, + "Copy Markdown" : { + }, "Copy Secret" : { "localizations" : { @@ -110756,6 +110765,12 @@ } } } + }, + "Diagnostic Report" : { + + }, + "Diagnostic report" : { + }, "Dings" : { "comment" : "Name of a sound file.", @@ -164692,6 +164707,9 @@ } } } + }, + "Identifiers such as URLs, secrets, and device serials are replaced before sharing." : { + }, "If \"Display IOB and COB\" is also enabled, \"IOB\" and \"COB\" will be replaced with the following emojis:" : { "localizations" : { @@ -170094,6 +170112,9 @@ } } } + }, + "Include debug lines" : { + }, "Include IOB & COB in the calendar event data." : { "localizations" : { @@ -268678,6 +268699,9 @@ }, "Share Pump Logs" : { + }, + "Share Report" : { + }, "Shared Secret" : { "localizations" : { @@ -312312,6 +312336,9 @@ } } } + }, + "Time window" : { + }, "Time-Sensitive" : { "localizations" : { diff --git a/Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift b/Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift index 80b801f74..cdf487f96 100644 --- a/Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift +++ b/Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift @@ -1,3 +1,269 @@ import Foundation +import os +import Swinject -final class DiagnosticReportGenerator {} +final class DiagnosticReportGenerator: Injectable { + @Injected() private var deviceLogStore: DeviceLogStore! + @Injected() private var deviceLogExporter: DeviceLogExporting! + @Injected() private var deviceDataManager: DeviceDataManager! + @Injected() private var fetchGlucoseManager: FetchGlucoseManager! + @Injected() private var settingsManager: SettingsManager! + @Injected() private var keychain: Keychain! + @Injected() private var reachabilityManager: ReachabilityManager! + + private struct RenderedSection { + let title: String + let order: Int + let body: String + let directory: URL? + } + + private static let deviceLogSlice: TimeInterval = 6 * 60 * 60 + + private let sectionTimeout: TimeInterval + private let sectionsOverride: ((Bool) -> [DiagnosticReportSection])? + private let exactValuesOverride: ((DateInterval) async -> [Redactor.ExactValue])? + + init(resolver: Resolver) { + sectionTimeout = 10 + sectionsOverride = nil + exactValuesOverride = nil + injectServices(resolver) + } + + init( + sectionTimeout: TimeInterval, + sections: @escaping (Bool) -> [DiagnosticReportSection], + exactValues: @escaping (DateInterval) async -> [Redactor.ExactValue] = { _ in [] } + ) { + self.sectionTimeout = sectionTimeout + sectionsOverride = sections + exactValuesOverride = exactValues + } + + @discardableResult func generate(window: DateInterval, includeDebug: Bool, into directory: URL) async throws -> String { + let fileManager = FileManager.default + // Sections write into staging so a timed-out section can never add unredacted files to the report + let staging = fileManager.temporaryDirectory.appendingPathComponent("report-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: staging, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: staging) } + + let sections = sectionsOverride?(includeDebug) ?? defaultSections(includeDebug: includeDebug) + let timeout = sectionTimeout + let rendered = await withTaskGroup(of: RenderedSection.self) { group in + for (index, section) in sections.enumerated() { + let sectionDirectory = staging.appendingPathComponent("\(index)", isDirectory: true) + group.addTask { + try? FileManager.default.createDirectory(at: sectionDirectory, withIntermediateDirectories: true) + let body = await Self.render(section, window: window, into: sectionDirectory, timeout: timeout) + return RenderedSection( + title: section.title, + order: section.order, + body: body ?? "_timed out_", + directory: body == nil ? nil : sectionDirectory + ) + } + } + var results: [RenderedSection] = [] + for await result in group { + results.append(result) + } + return results.sorted { $0.order < $1.order } + } + + for section in rendered { + if let sectionDirectory = section.directory { + moveContents(of: sectionDirectory, to: directory, prefix: "\(section.order)-") + } + } + + let markdown = Self.markdown(for: rendered, window: window, generatedAt: Date()) + let reportURL = directory.appendingPathComponent("Trio Report \(ReportFormatting.fileStamp(Date())).md") + try markdown.write(to: reportURL, atomically: true, encoding: .utf8) + + let exactValues: [Redactor.ExactValue] + if let exactValuesOverride { + exactValues = await exactValuesOverride(window) + } else { + exactValues = await defaultExactValues(in: window) + } + try Redactor(exactValues: exactValues).redactFiles(in: directory) + return try String(contentsOf: reportURL, encoding: .utf8) + } + + func archive(window: DateInterval, includeDebug: Bool) async throws -> URL { + try await LogShareAction.archive(named: "Trio-Report-\(ReportFormatting.archiveStamp(Date()))") { directory in + _ = try await self.generate(window: window, includeDebug: includeDebug, into: directory) + } + } + + func markdown(window: DateInterval, includeDebug: Bool) async throws -> String { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + return try await generate(window: window, includeDebug: includeDebug, into: directory) + } + + private static func render( + _ section: DiagnosticReportSection, + window: DateInterval, + into directory: URL, + timeout: TimeInterval + ) async -> String? { + let finished = OSAllocatedUnfairLock(initialState: false) + let claim = { finished.withLock { done in + defer { done = true } + return !done + } } + return await withCheckedContinuation { continuation in + let work = Task { + let body = await section.render(window: window, into: directory) + if claim() { continuation.resume(returning: body) } + } + Task { + try? await Task.sleep(nanoseconds: UInt64(max(timeout, 0) * 1_000_000_000)) + if claim() { + work.cancel() + continuation.resume(returning: nil) + } + } + } + } + + private static func markdown(for sections: [RenderedSection], window: DateInterval, generatedAt: Date) -> String { + var lines = [ + "# Trio Report", + "", + "Generated at: \(ReportFormatting.timestamp(generatedAt))", + "Window: \(ReportFormatting.timestamp(window.start)) – \(ReportFormatting.timestamp(window.end))" + ] + for section in sections { + lines += ["", "## \(section.title)", "", section.body.trimmingCharacters(in: .newlines)] + } + return lines.joined(separator: "\n") + "\n" + } + + private func moveContents(of source: URL, to destination: URL, prefix: String) { + let fileManager = FileManager.default + guard let files = try? fileManager.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) else { return } + for file in files { + var target = destination.appendingPathComponent(file.lastPathComponent) + if fileManager.fileExists(atPath: target.path) { + target = destination.appendingPathComponent(prefix + file.lastPathComponent) + } + do { + try fileManager.moveItem(at: file, to: target) + } catch { + warning(.service, "Could not add \(file.lastPathComponent) to the report: \(error)") + } + } + } + + private func defaultSections(includeDebug: Bool) -> [DiagnosticReportSection] { + let deviceDataManager = self.deviceDataManager + let fetchGlucoseManager = self.fetchGlucoseManager + return [ + BuildSection(), + DeviceInfoSection(), + SettingsSection(settingsManager: settingsManager), + DeviceManagersSection( + pumpManager: { deviceDataManager?.pumpManager }, + cgmManager: { fetchGlucoseManager?.cgmManager } + ), + AlertsSection(), + DeterminationsSection(), + LoopTimingSection(), + NightscoutSection(settingsManager: settingsManager, keychain: keychain, reachabilityManager: reachabilityManager), + WatchSection(), + AppLogSection(includeDebug: includeDebug), + DeviceLogSection(exporter: deviceLogExporter) + ] + } + + private func defaultExactValues(in window: DateInterval) async -> [Redactor.ExactValue] { + var values: [Redactor.ExactValue] = [] + let url: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) + if let url, !url.isEmpty { + values.append(Redactor.ExactValue(value: url, kind: .url)) + if url.hasSuffix("/") { + values.append(Redactor.ExactValue(value: String(url.dropLast()), kind: .url)) + } + if let host = URL(string: url)?.host { + values.append(Redactor.ExactValue(value: host, kind: .host)) + } + } + let secret: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) + if let secret, !secret.isEmpty { + values.append(Redactor.ExactValue(value: secret, kind: .secret)) + } + for identifier in await deviceIdentifiers(in: window).sorted() { + values.append(Redactor.ExactValue(value: identifier, kind: .device)) + } + return values + } + + private func deviceIdentifiers(in window: DateInterval) async -> Set { + guard let deviceLogStore else { return [] } + var identifiers = Set() + var sliceStart = window.start + while sliceStart < window.end { + let sliceEnd = min(sliceStart.addingTimeInterval(Self.deviceLogSlice), window.end) + for entry in await deviceLogStore.entries(in: DateInterval(start: sliceStart, end: sliceEnd)) { + if let identifier = entry.deviceIdentifier, !identifier.isEmpty { + identifiers.insert(identifier) + } + } + sliceStart = sliceEnd + } + return identifiers + } +} + +enum ReportFormatting { + private static let timestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + 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 + }() + + static func timestamp(_ date: Date?) -> String { + date.map { timestampFormatter.string(from: $0) } ?? "-" + } + + static func number(_ value: Decimal?) -> String { + value.flatMap { numberFormatter.string(from: $0 as NSDecimalNumber) } ?? "-" + } + + static func number(_ value: Double) -> String { + numberFormatter.string(from: NSNumber(value: value)) ?? "\(value)" + } + + static func fenced(_ text: String) -> String { + let fence = text.contains("```") ? "~~~~" : "```" + return "\(fence)\n\(text.trimmingCharacters(in: .newlines))\n\(fence)" + } + + static func fileStamp(_ date: Date) -> String { + stamp(date, format: "yyyy-MM-dd HH-mm") + } + + static func archiveStamp(_ date: Date) -> String { + stamp(date, format: "yyyyMMdd-HHmm") + } + + private static func stamp(_ date: Date, format: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = format + return formatter.string(from: date) + } +} diff --git a/Trio/Sources/Logger/Report/DiagnosticReportView.swift b/Trio/Sources/Logger/Report/DiagnosticReportView.swift index 651848cd7..a17fdfdb4 100644 --- a/Trio/Sources/Logger/Report/DiagnosticReportView.swift +++ b/Trio/Sources/Logger/Report/DiagnosticReportView.swift @@ -1,7 +1,130 @@ import SwiftUI +import UIKit struct DiagnosticReportView: View { + let generator: DiagnosticReportGenerator? + + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + @Environment(AppState.self) private var appState + + @State private var days = 3 + @State private var includeDebug = true + @State private var isGenerating = false + @State private var archive: URL? + @State private var showShareSheet = false + @State private var showCopied = false + @State private var showError = false + + private static let dayOptions = [1, 3, 7] + var body: some View { - EmptyView() + NavigationStack { + List { + Section( + content: { + Picker("Time window", selection: $days) { + ForEach(Self.dayOptions, id: \.self) { day in + day == 1 ? Text("1 day") : Text("\(day) days") + } + } + Toggle("Include debug lines", isOn: $includeDebug) + }, + footer: { + Text("Identifiers such as URLs, secrets, and device serials are replaced before sharing.") + } + ) + .listRowBackground(Color.chart) + + Section( + content: { + Button(action: share) { + HStack { + Text("Share Report") + Spacer() + if isGenerating { + ProgressView() + } + } + } + Button(action: copyMarkdown) { + HStack { + Text("Copy Markdown") + Spacer() + if showCopied { + Label("Copied", systemImage: "checkmark.circle.fill") + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + }, + footer: { + if showError { + Text("Could not prepare logs").foregroundColor(.red) + } + } + ) + .disabled(isGenerating || generator == nil) + .listRowBackground(Color.chart) + } + .scrollContentBackground(.hidden) + .background(appState.trioBackgroundColor(for: colorScheme)) + .navigationTitle("Diagnostic Report") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { dismiss() } + } + } + .sheet(isPresented: $showShareSheet, onDismiss: discardArchive) { + ShareSheet(activityItems: archive.map { [$0] } ?? []) + } + .onDisappear(perform: discardArchive) + } + } + + private var window: DateInterval { + let now = Date() + return DateInterval(start: now.addingTimeInterval(-Double(days) * 24 * 60 * 60), end: now) + } + + private func share() { + run { generator in + archive = try await generator.archive(window: window, includeDebug: includeDebug) + showShareSheet = true + } + } + + private func copyMarkdown() { + run { generator in + UIPasteboard.general.string = try await generator.markdown(window: window, includeDebug: includeDebug) + UINotificationFeedbackGenerator().notificationOccurred(.success) + withAnimation { showCopied = true } + try? await Task.sleep(nanoseconds: 1_500_000_000) + withAnimation { showCopied = false } + } + } + + private func run(_ action: @escaping @MainActor(DiagnosticReportGenerator) async throws -> Void) { + guard let generator else { return } + isGenerating = true + showError = false + Task { @MainActor in + do { + try await action(generator) + } catch { + warning(.service, "Could not prepare diagnostic report: \(error)") + showError = true + } + isGenerating = false + } + } + + private func discardArchive() { + if let archive { + LogShareAction.discard(archive) + } + archive = nil } } diff --git a/Trio/Sources/Logger/Report/RedactionPatterns.swift b/Trio/Sources/Logger/Report/RedactionPatterns.swift index 3677104dc..ef68b7e1a 100644 --- a/Trio/Sources/Logger/Report/RedactionPatterns.swift +++ b/Trio/Sources/Logger/Report/RedactionPatterns.swift @@ -1,3 +1,38 @@ import Foundation -enum RedactionPatterns {} +enum RedactionPatterns { + struct Pattern { + let regex: NSRegularExpression + let kind: Redactor.Kind + let group: Int + } + + // Order matters: Omnipod lot/TID digits run before the Dexcom TID pattern + static let all: [Pattern] = [ + make(#"https?://[^\s"'<>)]+"#, kind: .url), + make(#"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"#, kind: .email), + make(#"Bearer\s+([A-Za-z0-9._-]+)"#, kind: .secret, group: 1), + make(#"(?i)\b(?:lot|tid)[:\s]+(\d+)\b"#, kind: .pumpSerial, group: 1), + make(#"(?i)\b(?:transmitter(?:ID)?|TID)[:\s"=]+([A-Za-z0-9]{6})\b"#, kind: .cgmSerial, group: 1), + make(labelled: ["lotNo", "lotSeq", "pumpSN"], kind: .pumpSerial), + make(labelled: ["sensorID", "sensorSerial", "transmitterSerial", "bleNameString"], kind: .cgmSerial), + make(labelled: ["deviceName", "serialNumber"], kind: .serial), + make(#"\b[0-9a-fA-F]{40}\b"#, kind: .secret) + ].compactMap { $0 } + + static let ignoredValues: Set = ["nil", "none", "unknown"] + + private static func make(_ pattern: String, kind: Redactor.Kind, group: Int = 0) -> Pattern? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + return Pattern(regex: regex, kind: kind, group: group) + } + + // Kit debug descriptions print serials as `label: value` or `label: Optional("value")` + private static func make(labelled labels: [String], kind: Redactor.Kind) -> Pattern? { + make( + #"(?i)\b(?:"# + labels.joined(separator: "|") + #")"?\s*[:=]\s*(?:Optional\()?"?([^\s"),<][^\s"),]{2,})"#, + kind: kind, + group: 1 + ) + } +} diff --git a/Trio/Sources/Logger/Report/Redactor.swift b/Trio/Sources/Logger/Report/Redactor.swift index ac0a1c98c..28f79f3e3 100644 --- a/Trio/Sources/Logger/Report/Redactor.swift +++ b/Trio/Sources/Logger/Report/Redactor.swift @@ -1,3 +1,134 @@ import Foundation -enum Redactor {} +final class Redactor { + enum Kind: String { + case url + case host + case secret + case device + case email + case pumpSerial = "pump-serial" + case cgmSerial = "cgm-serial" + case serial + } + + struct ExactValue: Equatable { + let value: String + let kind: Kind + } + + static let textExtensions: Set = ["md", "txt", "json", "log", "csv"] + private static let minimumExactLength = 4 + private static let chunkSize = 1 << 20 + + private let exactValues: [ExactValue] + private let patterns: [RedactionPatterns.Pattern] + private var placeholders: [String: String] = [:] + private var counters: [Kind: Int] = [:] + + init(exactValues: [ExactValue], patterns: [RedactionPatterns.Pattern] = RedactionPatterns.all) { + var seen = Set() + self.exactValues = exactValues + .map { ExactValue(value: $0.value.trimmingCharacters(in: .whitespacesAndNewlines), kind: $0.kind) } + .filter { $0.value.count >= Self.minimumExactLength && seen.insert($0.value.lowercased()).inserted } + .sorted { $0.value.count > $1.value.count } + self.patterns = patterns + } + + func redact(_ text: String) -> String { + var result = text + for exact in exactValues where result.range(of: exact.value, options: .caseInsensitive) != nil { + result = result.replacingOccurrences( + of: exact.value, + with: placeholder(for: exact.value, kind: exact.kind), + options: .caseInsensitive + ) + } + for pattern in patterns { + result = apply(pattern, to: result) + } + return result + } + + // Rewrites text files in place and removes anything that cannot be redacted + func redactFiles(in directory: URL) throws { + let fileManager = FileManager.default + guard let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey]) + else { return } + // Collect first so the temporary rewrite files are never enumerated + let urls = enumerator.allObjects.compactMap { $0 as? URL } + for url in urls { + guard (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { continue } + guard Self.textExtensions.contains(url.pathExtension.lowercased()) else { + try fileManager.removeItem(at: url) + continue + } + do { + try redactFile(at: url) + } catch { + warning(.service, "Could not redact \(url.lastPathComponent), leaving it out: \(error)") + if fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + } + } + } + } + + func redactFile(at url: URL) throws { + let fileManager = FileManager.default + let data = try Data(contentsOf: url, options: .mappedIfSafe) + let output = url.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).redacting") + guard fileManager.createFile(atPath: output.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: output.path]) + } + do { + let handle = try FileHandle(forWritingTo: output) + defer { try? handle.close() } + var start = data.startIndex + while start < data.endIndex { + // Patterns never span lines, so chunks end on a newline + var end = min(start + Self.chunkSize, data.endIndex) + if end < data.endIndex { + end = data[end...].firstIndex(of: 0x0A).map { $0 + 1 } ?? data.endIndex + } + let chunk = String(decoding: data[start ..< end], as: UTF8.self) + try handle.write(contentsOf: Data(redact(chunk).utf8)) + start = end + } + try fileManager.removeItem(at: url) + try fileManager.moveItem(at: output, to: url) + } catch { + try? fileManager.removeItem(at: output) + throw error + } + } + + private func apply(_ pattern: RedactionPatterns.Pattern, to text: String) -> String { + let source = text as NSString + let matches = pattern.regex.matches(in: text, range: NSRange(location: 0, length: source.length)) + guard !matches.isEmpty else { return text } + var output = "" + var cursor = 0 + for match in matches { + let range = match.range(at: pattern.group) + guard range.location != NSNotFound, range.location >= cursor else { continue } + let value = source.substring(with: range) + guard !RedactionPatterns.ignoredValues.contains(value.lowercased()) else { continue } + output += source.substring(with: NSRange(location: cursor, length: range.location - cursor)) + output += placeholder(for: value, kind: pattern.kind) + cursor = range.location + range.length + } + output += source.substring(from: cursor) + return output + } + + private func placeholder(for value: String, kind: Kind) -> String { + 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 + } +} diff --git a/Trio/Sources/Logger/Report/Sections/AlertsSection.swift b/Trio/Sources/Logger/Report/Sections/AlertsSection.swift index c5908f787..0de920162 100644 --- a/Trio/Sources/Logger/Report/Sections/AlertsSection.swift +++ b/Trio/Sources/Logger/Report/Sections/AlertsSection.swift @@ -1,3 +1,43 @@ import Foundation -struct AlertsSection {} +struct AlertsSection: DiagnosticReportSection { + let title = "Alerts" + let order = 40 + var defaults: UserDefaults = .standard + + // Read-only view of BaseAlertHistoryStorage's persisted history + static let historyKey = "openaps.monitor.alertHistory.data" + private static let limit = 200 + + func render(window: DateInterval, into _: URL) async -> String { + guard let data = defaults.data(forKey: Self.historyKey), + let alerts = try? JSONCoding.decoder.decode([AlertEntry].self, from: data) + else { return "_No alert history_" } + let inWindow = alerts.filter { window.contains($0.issuedDate) }.sorted { $0.issuedDate > $1.issuedDate } + guard !inWindow.isEmpty else { return "_No alerts in window (Trio keeps 24 hours of alert history)_" } + var lines = ["Trio keeps 24 hours of alert history. Newest first."] + lines.append("") + lines += inWindow.prefix(Self.limit).map(Self.line) + if inWindow.count > Self.limit { + lines.append("- … \(inWindow.count - Self.limit) older alerts omitted") + } + return lines.joined(separator: "\n") + } + + private static func line(_ alert: AlertEntry) -> String { + var parts = [ + ReportFormatting.timestamp(alert.issuedDate), + "\(alert.managerIdentifier)/\(alert.alertIdentifier)", + "trigger \(alert.triggerType)" + ] + 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 \(ReportFormatting.timestamp($0))" } ?? "unacknowledged") + if let error = alert.errorMessage { + parts.append("error: \(error)") + } + return "- " + parts.joined(separator: " · ") + } +} diff --git a/Trio/Sources/Logger/Report/Sections/AppLogSection.swift b/Trio/Sources/Logger/Report/Sections/AppLogSection.swift index 3f3110c65..130cfa50b 100644 --- a/Trio/Sources/Logger/Report/Sections/AppLogSection.swift +++ b/Trio/Sources/Logger/Report/Sections/AppLogSection.swift @@ -1,3 +1,75 @@ import Foundation -struct AppLogSection {} +struct AppLogSection: DiagnosticReportSection { + let title = "App log" + let order = 90 + let includeDebug: Bool + var sink: () -> any LogSink = { TrioLogger.sink } + var legacyDirectory: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("logs", isDirectory: true) + + static let legacyFiles = ["log.txt", "log_prev.txt", "watch_log.txt"] + private static let markdownLimit = 200 + + func render(window: DateInterval, into directory: URL) async -> String { + let sink = self.sink() + await sink.flush() + let minimumLevel: LogLevel = includeDebug ? .debug : .info + let records = await sink.records(in: window, minimumLevel: minimumLevel).sorted { $0.timestamp < $1.timestamp } + + var notes = ["\(records.count) records at \(minimumLevel) and above in AppLog.txt."] + do { + try write(records, to: directory.appendingPathComponent("AppLog.txt")) + } catch { + notes.append("AppLog.txt could not be written: \(error.localizedDescription)") + } + let copied = copyLegacyFiles(into: directory) + if !copied.isEmpty { + notes.append("Older text logs: \(copied.joined(separator: ", ")).") + } + + let warnings = records.filter { $0.level >= .warning }.suffix(Self.markdownLimit).map(Self.line) + guard !warnings.isEmpty else { return (notes + ["", "_No warnings or errors in window_"]).joined(separator: "\n") } + let tail = [ + "", + "Last \(warnings.count) warnings and errors:", + "", + ReportFormatting.fenced(warnings.joined(separator: "\n")) + ] + return (notes + tail).joined(separator: "\n") + } + + static func line(_ record: LogRecord) -> String { + "\(ReportFormatting.timestamp(record.timestamp)) [\(String(describing: record.level).uppercased())] [\(record.category)] \(record.file):\(record.line) \(record.function) - \(record.message)" + } + + private func write(_ records: [LogRecord], to url: URL) 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() } + var start = 0 + while start < records.count { + let end = min(start + 1000, records.count) + let chunk = records[start ..< end].map { Self.line($0) + "\n" }.joined() + try handle.write(contentsOf: Data(chunk.utf8)) + start = end + } + } + + private func copyLegacyFiles(into directory: URL) -> [String] { + Self.legacyFiles.compactMap { name in + let source = legacyDirectory.appendingPathComponent(name) + guard FileManager.default.fileExists(atPath: source.path) else { return nil } + let target = "Legacy-\(name)" + do { + try FileManager.default.copyItem(at: source, to: directory.appendingPathComponent(target)) + return target + } catch { + warning(.service, "Could not copy \(name) into the report: \(error)") + return nil + } + } + } +} diff --git a/Trio/Sources/Logger/Report/Sections/BuildSection.swift b/Trio/Sources/Logger/Report/Sections/BuildSection.swift index 3605ab46f..c00ce315b 100644 --- a/Trio/Sources/Logger/Report/Sections/BuildSection.swift +++ b/Trio/Sources/Logger/Report/Sections/BuildSection.swift @@ -1,3 +1,28 @@ import Foundation -struct BuildSection {} +struct BuildSection: DiagnosticReportSection { + let title = "Build" + let order = 0 + var buildDetails: BuildDetails = .shared + var bundle: Bundle = .main + + func render(window _: DateInterval, into _: URL) async -> String { + let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let build = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "unknown" + var lines = [ + "- Version: \(version) (\(build))", + "- Dev version: \(bundle.appDevVersion ?? "unknown")", + "- Branch: \(buildDetails.trioBranch)", + "- Commit: \(buildDetails.trioCommitSHA)", + "- Built: \(buildDetails.buildDateString ?? "unknown")", + "- Expires: \(ReportFormatting.timestamp(buildDetails.calculateExpirationDate()))", + "- TestFlight: \(buildDetails.isTestFlightBuild() ? "yes" : "no")" + ] + let submodules = buildDetails.submodules.sorted { $0.key < $1.key } + if !submodules.isEmpty { + lines.append("- Submodules:") + lines += submodules.map { " - \($0.key): \($0.value.branch) \($0.value.commitSHA)" } + } + return lines.joined(separator: "\n") + } +} diff --git a/Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift b/Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift index 1a7bb8990..6b6bba0ff 100644 --- a/Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift +++ b/Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift @@ -1,3 +1,139 @@ +import CoreData import Foundation -struct DeterminationsSection {} +struct DeterminationSnapshot: Codable, Equatable { + var id: UUID? + var deliverAt: Date? + var timestamp: Date? + var timestampEnacted: Date? + var enacted: Bool + var received: Bool + var isUploadedToNS: Bool + var glucose: Decimal? + var iob: Decimal? + var cob: Int16 + var insulinSensitivity: Decimal? + var sensitivityRatio: Decimal? + var currentTarget: Decimal? + var eventualBG: Decimal? + var rate: Decimal? + var duration: Decimal? + var temp: String? + var smbToDeliver: Decimal? + var insulinReq: Decimal? + var carbRatio: Decimal? + var carbsRequired: Int16 + var scheduledBasal: Decimal? + var reservoir: Decimal? + var threshold: Decimal? + var expectedDelta: Decimal? + var minDelta: Decimal? + var reason: String? + var forecasts: [String: [Int32]] + + var markdown: String { + let fields = [ + "\(ReportFormatting.timestamp(deliverAt ?? timestamp)) \(enacted ? "enacted" : "suggested")", + "glucose \(ReportFormatting.number(glucose))", + "iob \(ReportFormatting.number(iob))", + "cob \(cob)", + "isf \(ReportFormatting.number(insulinSensitivity))", + "sensitivityRatio \(ReportFormatting.number(sensitivityRatio))", + "rate \(ReportFormatting.number(rate)) for \(ReportFormatting.number(duration)) min", + "smbToDeliver \(ReportFormatting.number(smbToDeliver))", + "eventualBG \(ReportFormatting.number(eventualBG))", + "target \(ReportFormatting.number(currentTarget))" + ] + let fence = ReportFormatting.fenced(reason ?? "").components(separatedBy: "\n").map { " \($0)" } + return (["- " + fields.joined(separator: " · ")] + fence).joined(separator: "\n") + } + + static func markdown(for snapshots: [DeterminationSnapshot], limit: Int = 100) -> String { + guard !snapshots.isEmpty else { return "_No determinations in window_" } + let newestFirst = snapshots.sorted { ($0.deliverAt ?? .distantPast) > ($1.deliverAt ?? .distantPast) } + var lines = ["\(snapshots.count) runs in window, newest first. All runs with forecasts in Determinations.json."] + if snapshots.count > limit { + lines.append("Showing the newest \(limit).") + } + lines.append("") + lines += newestFirst.prefix(limit).map(\.markdown) + return lines.joined(separator: "\n") + } +} + +extension DeterminationSnapshot { + init(_ determination: OrefDetermination) { + var forecasts: [String: [Int32]] = [:] + for forecast in determination.forecasts ?? [] { + let values = (forecast.forecastValues ?? []).sorted { $0.index < $1.index }.map(\.value) + forecasts[forecast.type ?? "unknown", default: []] += values + } + self.init( + id: determination.id, + deliverAt: determination.deliverAt, + timestamp: determination.timestamp, + timestampEnacted: determination.timestampEnacted, + enacted: determination.enacted, + received: determination.received, + isUploadedToNS: determination.isUploadedToNS, + glucose: determination.glucose?.decimalValue, + iob: determination.iob?.decimalValue, + cob: determination.cob, + insulinSensitivity: determination.insulinSensitivity?.decimalValue, + sensitivityRatio: determination.sensitivityRatio?.decimalValue, + currentTarget: determination.currentTarget?.decimalValue, + eventualBG: determination.eventualBG?.decimalValue, + rate: determination.rate?.decimalValue, + duration: determination.duration?.decimalValue, + temp: determination.temp, + smbToDeliver: determination.smbToDeliver?.decimalValue, + insulinReq: determination.insulinReq?.decimalValue, + carbRatio: determination.carbRatio?.decimalValue, + carbsRequired: determination.carbsRequired, + scheduledBasal: determination.scheduledBasal?.decimalValue, + reservoir: determination.reservoir?.decimalValue, + threshold: determination.threshold?.decimalValue, + expectedDelta: determination.expectedDelta?.decimalValue, + minDelta: determination.minDelta?.decimalValue, + reason: determination.reason, + forecasts: forecasts + ) + } +} + +struct DeterminationsSection: DiagnosticReportSection { + let title = "Determinations" + let order = 50 + var makeContext: () -> NSManagedObjectContext = { CoreDataStack.shared.newTaskContext() } + + func render(window: DateInterval, into directory: URL) async -> String { + let context = makeContext() + context.name = "diagnosticReportDeterminations" + let snapshots: [DeterminationSnapshot] + do { + snapshots = 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.sortDescriptors = [NSSortDescriptor(key: "deliverAt", ascending: false)] + request.relationshipKeyPathsForPrefetching = ["forecasts", "forecasts.forecastValues"] + request.fetchBatchSize = 100 + return try context.fetch(request).map(DeterminationSnapshot.init) + } + } catch { + return "_Determinations could not be fetched: \(error.localizedDescription)_" + } + + let encoder = JSONCoding.encoder + encoder.outputFormatting.insert(.sortedKeys) + do { + try encoder.encode(snapshots).write(to: directory.appendingPathComponent("Determinations.json")) + } catch { + warning(.service, "Could not write determinations for the report: \(error)") + } + return DeterminationSnapshot.markdown(for: snapshots) + } +} diff --git a/Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift b/Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift index 84161103b..203f6e712 100644 --- a/Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift +++ b/Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift @@ -1,3 +1,47 @@ import Foundation -struct DeviceInfoSection {} +struct DeviceInfoSection: DiagnosticReportSection { + let title = "Device" + let order = 10 + + func render(window _: DateInterval, into _: URL) async -> String { + let processInfo = ProcessInfo.processInfo + return [ + "- OS: \(processInfo.operatingSystemVersionString)", + "- Model: \(Self.modelIdentifier)", + "- Low Power Mode: \(processInfo.isLowPowerModeEnabled ? "on" : "off")", + "- Thermal state: \(Self.label(for: processInfo.thermalState))", + "- Free disk: \(Self.freeDisk)", + "- Time zone: \(TimeZone.current.identifier)" + ].joined(separator: "\n") + } + + 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 freeDisk: 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) + } + + private static func label(for state: ProcessInfo.ThermalState) -> String { + switch state { + case .nominal: return "nominal" + case .fair: return "fair" + case .serious: return "serious" + case .critical: return "critical" + @unknown default: return "unknown" + } + } +} diff --git a/Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift b/Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift index 25d1c65f2..c0663a426 100644 --- a/Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift +++ b/Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift @@ -1,3 +1,45 @@ import Foundation -struct DeviceLogSection {} +struct DeviceLogSection: DiagnosticReportSection { + let title = "Device log" + let order = 100 + let exporter: DeviceLogExporting? + + private static let tailLines = 100 + + func render(window: DateInterval, into directory: URL) async -> String { + guard let exporter else { return "_Device log unavailable_" } + var lines: [String] = [] + do { + try await exporter.export( + DeviceLogExportRequest(window: window, managerIdentifier: nil, includeOSLog: true, includeKitFiles: true), + into: directory + ) + } catch { + lines.append("Export stopped early: \(error.localizedDescription)") + } + + let files = ((try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []).sorted() + let deviceLogs = files.filter { $0.hasPrefix("DeviceLog-") && $0.hasSuffix(".txt") } + let others = files.filter { !deviceLogs.contains($0) } + if !others.isEmpty { + lines.append("Also attached: \(others.joined(separator: ", ")).") + } + guard !deviceLogs.isEmpty else { return (lines + ["", "_No pump or CGM device log_"]).joined(separator: "\n") } + + for name in deviceLogs { + let url = directory.appendingPathComponent(name) + let entries = ((try? String(contentsOf: url, encoding: .utf8)) ?? "") + .components(separatedBy: "\n") + .filter { $0.hasPrefix("* ") } + lines += ["", "### \(name)", ""] + if entries.isEmpty { + lines.append("_No entries in window_") + } else { + lines.append("Last \(min(entries.count, Self.tailLines)) of \(entries.count) entries:") + lines += ["", ReportFormatting.fenced(entries.suffix(Self.tailLines).joined(separator: "\n"))] + } + } + return lines.joined(separator: "\n") + } +} diff --git a/Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift b/Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift index db07d4ddd..bbe14f67c 100644 --- a/Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift +++ b/Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift @@ -1,3 +1,45 @@ import Foundation +import LoopKit -struct DeviceManagersSection {} +struct DeviceManagersSection: DiagnosticReportSection { + let title = "Pump and CGM" + let order = 30 + let pumpManager: @MainActor() -> DeviceManager? + let cgmManager: @MainActor() -> DeviceManager? + + private static let descriptionLineLimit = 90 + + private struct Snapshot { + let identifier: String + let title: String + let description: String + } + + func render(window _: DateInterval, into _: URL) async -> String { + let (pump, cgm) = await MainActor.run { + (pumpManager().map(Self.snapshot), cgmManager().map(Self.snapshot)) + } + return [Self.markdown(heading: "Pump", pump), Self.markdown(heading: "CGM", cgm)].joined(separator: "\n\n") + } + + private static func snapshot(_ manager: DeviceManager) -> Snapshot { + Snapshot(identifier: manager.pluginIdentifier, title: manager.localizedTitle, description: String(reflecting: manager)) + } + + private static func markdown(heading: String, _ snapshot: Snapshot?) -> String { + guard let snapshot else { return "### \(heading)\n\n_Not configured_" } + var description = snapshot.description.components(separatedBy: .newlines) + if description.count > descriptionLineLimit { + description = Array(description.prefix(descriptionLineLimit)) + description.append("… truncated, full state in DeviceLog-\(snapshot.identifier).txt") + } + return [ + "### \(heading)", + "", + "- Plugin: \(snapshot.identifier)", + "- Name: \(snapshot.title)", + "", + ReportFormatting.fenced(description.joined(separator: "\n")) + ].joined(separator: "\n") + } +} diff --git a/Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift b/Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift index ec4de9bd6..b22efdead 100644 --- a/Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift +++ b/Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift @@ -1,3 +1,60 @@ +import CoreData import Foundation -struct LoopTimingSection {} +struct LoopTimingSection: DiagnosticReportSection { + let title = "Loop timing" + let order = 60 + var makeContext: () -> NSManagedObjectContext = { CoreDataStack.shared.newTaskContext() } + + private static let limit = 50 + + private struct Run { + let start: Date? + let end: Date? + let seconds: Double + let status: String + } + + func render(window: DateInterval, into _: URL) async -> String { + let context = makeContext() + context.name = "diagnosticReportLoopTiming" + let runs: [Run] + do { + runs = try await context.perform { + let request: NSFetchRequest = LoopStatRecord.fetchRequest() + request.predicate = NSPredicate( + format: "start >= %@ AND start <= %@", + window.start as NSDate, + window.end as NSDate + ) + request.sortDescriptors = [NSSortDescriptor(key: "start", ascending: false)] + request.fetchLimit = Self.limit + return try context.fetch(request).map { record in + // Stored duration is in minutes + let seconds = record.start.flatMap { start in record.end.map { $0.timeIntervalSince(start) } } + ?? record.duration * 60 + return Run(start: record.start, end: record.end, seconds: seconds, status: record.loopStatus ?? "-") + } + } + } catch { + return "_Loop statistics could not be fetched: \(error.localizedDescription)_" + } + guard !runs.isEmpty else { return "_No loop runs in window_" } + + let durations = runs.map(\.seconds).sorted() + var lines = [ + "Last \(runs.count) runs, newest first. Median \(ReportFormatting.number(Self.median(durations))) s, max \(ReportFormatting.number(durations.last ?? 0)) s.", + "" + ] + lines += runs.map { + "- \(ReportFormatting.timestamp($0.start)) → \(ReportFormatting.timestamp($0.end)) · \(ReportFormatting.number($0.seconds)) s · \($0.status.replacingOccurrences(of: "\n", with: " "))" + } + return lines.joined(separator: "\n") + } + + static func median(_ sorted: [Double]) -> Double { + guard !sorted.isEmpty else { return 0 } + let middle = sorted.count / 2 + return sorted.count.isMultiple(of: 2) ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] + } +} diff --git a/Trio/Sources/Logger/Report/Sections/NightscoutSection.swift b/Trio/Sources/Logger/Report/Sections/NightscoutSection.swift index f3cf6c005..299dc8137 100644 --- a/Trio/Sources/Logger/Report/Sections/NightscoutSection.swift +++ b/Trio/Sources/Logger/Report/Sections/NightscoutSection.swift @@ -1,3 +1,59 @@ +import CoreData import Foundation -struct NightscoutSection {} +struct NightscoutSection: DiagnosticReportSection { + let title = "Nightscout" + let order = 70 + let settingsManager: SettingsManager? + let keychain: Keychain? + let reachabilityManager: ReachabilityManager? + var makeContext: () -> NSManagedObjectContext = { CoreDataStack.shared.newTaskContext() } + + func render(window: DateInterval, into _: URL) async -> String { + let url: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) + let secret: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) + let settings = settingsManager?.settings + var lines = [ + "- URL configured: \(Self.yesNo(url?.isEmpty == false))", + "- Host: \(url.flatMap { URL(string: $0)?.host } ?? "-")", + "- API secret configured: \(Self.yesNo(secret?.isEmpty == false))", + "- Upload enabled: \(Self.yesNo(settings?.isUploadEnabled))", + "- Download enabled: \(Self.yesNo(settings?.isDownloadEnabled))", + "- Upload glucose: \(Self.yesNo(settings?.uploadGlucose))", + "- Upload CGM sensor states: \(Self.yesNo(settings?.uploadCGMSensorStates))", + "- Nightscout as CGM: \(Self.yesNo(settings.map { $0.cgm == .nightscout }))", + "- Network reachable: \(Self.yesNo(reachabilityManager?.isReachable))" + ] + + let context = makeContext() + context.name = "diagnosticReportNightscout" + let start = window.start as NSDate + let end = window.end as NSDate + let summary: [String] = await context.perform { + func pending(_ entity: String, dateKey: String) -> String { + let request = NSFetchRequest(entityName: entity) + request.predicate = NSPredicate( + format: "%K >= %@ AND %K <= %@ AND isUploadedToNS == NO", + dateKey, start, dateKey, end + ) + return (try? context.count(for: request)).map(String.init) ?? "-" + } + let lastUploaded: NSFetchRequest = OrefDetermination.fetchRequest() + lastUploaded.predicate = NSPredicate(format: "isUploadedToNS == YES") + lastUploaded.sortDescriptors = [NSSortDescriptor(key: "deliverAt", ascending: false)] + lastUploaded.fetchLimit = 1 + let lastDate = (try? context.fetch(lastUploaded))?.first?.deliverAt + return [ + "- Newest uploaded determination: \(ReportFormatting.timestamp(lastDate))", + "- Not uploaded in window: determinations \(pending("OrefDetermination", dateKey: "deliverAt")), glucose \(pending("GlucoseStored", dateKey: "date")), carbs \(pending("CarbEntryStored", dateKey: "date")), pump events \(pending("PumpEventStored", dateKey: "timestamp"))" + ] + } + lines += summary + lines += ["", "Trio does not record individual upload or download results; see the nightscout lines in AppLog.txt."] + return lines.joined(separator: "\n") + } + + private static func yesNo(_ value: Bool?) -> String { + value.map { $0 ? "yes" : "no" } ?? "-" + } +} diff --git a/Trio/Sources/Logger/Report/Sections/SettingsSection.swift b/Trio/Sources/Logger/Report/Sections/SettingsSection.swift index 6d7c68f21..ed506de31 100644 --- a/Trio/Sources/Logger/Report/Sections/SettingsSection.swift +++ b/Trio/Sources/Logger/Report/Sections/SettingsSection.swift @@ -1,3 +1,62 @@ import Foundation -struct SettingsSection {} +struct SettingsSection: DiagnosticReportSection { + let title = "Settings" + let order = 20 + let settingsManager: SettingsManager? + + private static let groups = [ + ("settings", "Trio settings"), + ("preferences", "Algorithm preferences"), + ("pumpSettings", "Pump settings") + ] + + private struct Snapshot: Encodable { + let settings: TrioSettings + let preferences: Preferences + let pumpSettings: PumpSettings + } + + func render(window _: DateInterval, into directory: URL) async -> String { + guard let settingsManager else { return "_Settings unavailable_" } + let snapshot = Snapshot( + settings: settingsManager.settings, + preferences: settingsManager.preferences, + pumpSettings: settingsManager.pumpSettings + ) + let encoder = JSONCoding.encoder + encoder.outputFormatting.insert(.sortedKeys) + do { + let data = try encoder.encode(snapshot) + try data.write(to: directory.appendingPathComponent("Settings.json")) + guard let groups = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return "_Settings could not be listed_" + } + var lines = ["Full values in Settings.json."] + for (name, heading) in Self.groups { + guard let values = groups[name] as? [String: Any] else { continue } + lines += ["", "### \(heading)", ""] + lines += values.keys.sorted().map { "- \($0): \(Self.describe(values[$0]))" } + } + return lines.joined(separator: "\n") + } catch { + return "_Settings could not be encoded: \(error.localizedDescription)_" + } + } + + 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/Report/Sections/WatchSection.swift b/Trio/Sources/Logger/Report/Sections/WatchSection.swift index 27d30b03e..79fdb8874 100644 --- a/Trio/Sources/Logger/Report/Sections/WatchSection.swift +++ b/Trio/Sources/Logger/Report/Sections/WatchSection.swift @@ -1,3 +1,33 @@ import Foundation +import WatchConnectivity -struct WatchSection {} +struct WatchSection: DiagnosticReportSection { + let title = "Apple Watch" + let order = 80 + + func render(window _: DateInterval, into _: URL) async -> String { + guard WCSession.isSupported() else { return "_Watch connectivity not supported on this device_" } + let session = WCSession.default + var lines = ["- Session: \(Self.label(for: session.activationState))"] + // Pairing properties are only valid once the session is activated + if session.activationState == .activated { + lines += [ + "- Paired: \(session.isPaired ? "yes" : "no")", + "- Watch app installed: \(session.isWatchAppInstalled ? "yes" : "no")", + "- Reachable: \(session.isReachable ? "yes" : "no")", + "- Complication enabled: \(session.isComplicationEnabled ? "yes" : "no")", + "- Outstanding transfers: \(session.outstandingUserInfoTransfers.count)" + ] + } + return lines.joined(separator: "\n") + } + + private static func label(for state: WCSessionActivationState) -> String { + switch state { + case .activated: return "activated" + case .inactive: return "inactive" + case .notActivated: return "not activated" + @unknown default: return "unknown" + } + } +} diff --git a/Trio/Sources/Modules/Settings/SettingsStateModel.swift b/Trio/Sources/Modules/Settings/SettingsStateModel.swift index 3b97bf7e9..7286cd7d6 100644 --- a/Trio/Sources/Modules/Settings/SettingsStateModel.swift +++ b/Trio/Sources/Modules/Settings/SettingsStateModel.swift @@ -9,6 +9,7 @@ extension Settings { final class StateModel: BaseStateModel { @Injected() private var broadcaster: Broadcaster! @Injected() private var deviceLogExporter: DeviceLogExporting! + @Injected() var diagnosticReportGenerator: DiagnosticReportGenerator! @Injected() private var nightscoutManager: NightscoutManager! @Injected() var pluginManager: PluginManager! @Injected() var fetchCgmManager: FetchGlucoseManager! diff --git a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift index f225b5573..3a46a56b9 100644 --- a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift +++ b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift @@ -22,6 +22,7 @@ extension Settings { @State private var logArchive: URL? @State private var isPreparingLogs = false @State private var showLogErrorToast = false + @State private var showDiagnosticReport = false @State private var searchText: String = "" @State var hintDetent = PresentationDetent.large @State private var hintPayload: HintPayload? @@ -306,6 +307,7 @@ extension Settings { Menu { Button("Device logs") { shareLogs(state.deviceLogsArchive) } Button("App logs") { shareLogs(state.appLogsArchive) } + Button("Diagnostic report") { showDiagnosticReport = true } } label: { HStack { Text("Share Logs") @@ -495,6 +497,9 @@ extension Settings { }) { ShareSheet(activityItems: logArchive.map { [$0] } ?? []) } + .sheet(isPresented: $showDiagnosticReport) { + DiagnosticReportView(generator: state.diagnosticReportGenerator) + } .onAppear(perform: configureView) .task { await releaseNotesService.load() diff --git a/TrioTests/Logging/DeterminationsSectionTests.swift b/TrioTests/Logging/DeterminationsSectionTests.swift index ea0258e74..d0cc203de 100644 --- a/TrioTests/Logging/DeterminationsSectionTests.swift +++ b/TrioTests/Logging/DeterminationsSectionTests.swift @@ -3,4 +3,84 @@ import Testing @testable import Trio -@Suite("Determinations Section Tests") struct DeterminationsSectionTests {} +@Suite("Determinations Section Tests") struct DeterminationsSectionTests { + private static func snapshot(at date: Date, enacted: Bool, glucose: Decimal, reason: String) -> DeterminationSnapshot { + DeterminationSnapshot( + id: UUID(), + deliverAt: date, + timestamp: date, + timestampEnacted: enacted ? date : nil, + enacted: enacted, + received: enacted, + isUploadedToNS: false, + glucose: glucose, + iob: Decimal(string: "1.25"), + cob: 12, + insulinSensitivity: 45, + sensitivityRatio: Decimal(string: "0.9"), + currentTarget: 100, + eventualBG: 140, + rate: Decimal(string: "0.8"), + duration: 30, + temp: "absolute", + smbToDeliver: Decimal(string: "0.3"), + insulinReq: Decimal(string: "0.6"), + carbRatio: 10, + carbsRequired: 0, + scheduledBasal: 1, + reservoir: 100, + threshold: 70, + expectedDelta: 2, + minDelta: 1, + reason: reason, + forecasts: ["IOB": [123, 125, 127]] + ) + } + + @Test("Runs render newest first with a fenced reason") func rendersBullets() throws { + let older = Self.snapshot( + at: Date(timeIntervalSince1970: 1_790_000_000), + enacted: false, + glucose: 118, + reason: "COB: 12, Dev: 5; Eventual BG 140 >= 100" + ) + let newer = Self.snapshot( + at: Date(timeIntervalSince1970: 1_790_000_300), + enacted: true, + glucose: 123, + reason: "minPredBG 110; temp 0.8 >= 0.75" + ) + + let markdown = DeterminationSnapshot.markdown(for: [older, newer]) + let bullets = markdown.components(separatedBy: "\n").filter { $0.hasPrefix("- ") } + #expect(bullets.count == 2) + #expect(bullets[0].contains("enacted · glucose 123 · iob 1.25 · cob 12")) + #expect(bullets[0].contains("rate 0.8 for 30 min · smbToDeliver 0.3 · eventualBG 140 · target 100")) + #expect(bullets[1].contains("suggested · glucose 118")) + #expect(markdown.contains(" ```\n minPredBG 110; temp 0.8 >= 0.75\n ```")) + #expect(markdown.hasPrefix("2 runs in window")) + } + + @Test("Markdown is capped and the cap is noted") func capsRuns() { + let runs = (0 ..< 5).map { + Self.snapshot( + at: Date(timeIntervalSince1970: 1_790_000_000 + Double($0) * 300), + enacted: true, + glucose: 100, + reason: "r" + ) + } + let markdown = DeterminationSnapshot.markdown(for: runs, limit: 3) + #expect(markdown.components(separatedBy: "\n").filter { $0.hasPrefix("- ") }.count == 3) + #expect(markdown.contains("Showing the newest 3.")) + } + + @Test("Snapshots round-trip through JSON with forecasts") func encodesSnapshot() throws { + let value = Self.snapshot(at: Date(timeIntervalSince1970: 1_790_000_000), enacted: true, glucose: 123, reason: "r") + let data = try JSONCoding.encoder.encode([value]) + let decoded = try JSONCoding.decoder.decode([DeterminationSnapshot].self, from: data) + #expect(decoded.first?.forecasts == ["IOB": [123, 125, 127]]) + #expect(decoded.first?.reason == "r") + #expect(decoded.first?.deliverAt == value.deliverAt) + } +} diff --git a/TrioTests/Logging/DiagnosticReportGeneratorTests.swift b/TrioTests/Logging/DiagnosticReportGeneratorTests.swift index de3aa4815..c44d0265f 100644 --- a/TrioTests/Logging/DiagnosticReportGeneratorTests.swift +++ b/TrioTests/Logging/DiagnosticReportGeneratorTests.swift @@ -3,4 +3,74 @@ import Testing @testable import Trio -@Suite("Diagnostic Report Generator Tests") struct DiagnosticReportGeneratorTests {} +@Suite("Diagnostic Report Generator Tests") struct DiagnosticReportGeneratorTests { + private struct FakeSection: DiagnosticReportSection { + let title: String + let order: Int + var delay: TimeInterval = 0 + var sideFile: String? + + func render(window _: DateInterval, into directory: URL) async -> String { + if delay > 0 { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + if let sideFile { + try? Data("contact admin@example.com\n".utf8).write(to: directory.appendingPathComponent(sideFile)) + } + return "body of \(title)" + } + } + + 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 let window = DateInterval(start: Date(timeIntervalSince1970: 1_790_000_000), duration: 3 * 24 * 60 * 60) + + @Test("Sections are ordered and a slow section times out") func orderingAndTimeout() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let generator = DiagnosticReportGenerator(sectionTimeout: 0.3, sections: { _ in + [ + FakeSection(title: "Third", order: 30), + FakeSection(title: "Slow", order: 20, delay: 5, sideFile: "Slow.txt"), + FakeSection(title: "First", order: 10, sideFile: "First.txt") + ] + }) + + let started = Date() + let markdown = try await generator.generate(window: Self.window, includeDebug: true, into: directory) + #expect(Date().timeIntervalSince(started) < 3) + + let headings = markdown.components(separatedBy: "\n").filter { $0.hasPrefix("## ") } + #expect(headings == ["## First", "## Slow", "## Third"]) + #expect(markdown.contains("## Slow\n\n_timed out_")) + #expect(markdown.contains("body of First")) + #expect(markdown.hasPrefix("# Trio Report")) + #expect(markdown.contains("Window: ")) + + let files = try FileManager.default.contentsOfDirectory(atPath: directory.path) + #expect(files.contains("First.txt")) + #expect(!files.contains("Slow.txt")) + #expect(files.contains { $0.hasPrefix("Trio Report ") && $0.hasSuffix(".md") }) + } + + @Test("Side files and Markdown are redacted") func redactsBundle() async throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let generator = DiagnosticReportGenerator( + sectionTimeout: 2, + sections: { _ in [FakeSection(title: "Device ABC-1234", order: 0, sideFile: "Side.txt")] }, + exactValues: { _ in [Redactor.ExactValue(value: "ABC-1234", kind: .device)] } + ) + + let markdown = try await generator.generate(window: Self.window, includeDebug: false, into: directory) + #expect(markdown.contains("## Device ")) + #expect(!markdown.contains("ABC-1234")) + + let side = try String(contentsOf: directory.appendingPathComponent("Side.txt"), encoding: .utf8) + #expect(side == "contact \n") + } +} diff --git a/TrioTests/Logging/RedactorTests.swift b/TrioTests/Logging/RedactorTests.swift index e8f94389f..d03a5a506 100644 --- a/TrioTests/Logging/RedactorTests.swift +++ b/TrioTests/Logging/RedactorTests.swift @@ -3,4 +3,84 @@ import Testing @testable import Trio -@Suite("Redactor Tests") struct RedactorTests {} +@Suite("Redactor Tests") struct RedactorTests { + @Test( + "Patterns replace identifiers", + arguments: [ + ("upload to https://example-site.test/api/v1/entries failed", "upload to failed"), + ("account demo.user+cgm@example.org linked", "account linked"), + ("Authorization: Bearer abc.DEF-123_xyz", "Authorization: Bearer "), + ("token 0123456789abcdef0123456789abcdef01234567 sent", "token sent"), + ("Pod Lot: 123456789 TID: 7654321", "Pod Lot: TID: "), + ("lot 42424242 paired", "lot paired"), + ("transmitterID: 8G1ABC connected", "transmitterID: connected"), + ("Transmitter 8GXY12 found", "Transmitter found"), + ("{\"transmitterID\":\"8G9Z99\"}", "{\"transmitterID\":\"\"}"), + ("TID 8GAA11 battery ok", "TID battery ok"), + ("* lotNo: 135792468", "* lotNo: "), + ("* pumpSN: 0a1b2c3d", "* pumpSN: "), + ("sensorID: Optional(\"DX01AB\")", "sensorID: Optional(\"\")"), + ("* deviceName: Optional(\"meter+00042\")", "* deviceName: Optional(\"\")"), + ("bleNameString: nil", "bleNameString: nil") + ] + ) func patterns(input: String, expected: String) { + #expect(Redactor(exactValues: []).redact(input) == expected) + } + + @Test( + "Clinical values, timestamps, UUIDs and plugin identifiers stay untouched", + arguments: [ + "BG 123 mg/dL", + "IOB 1.25", + "2026-10-18T12:00:00.000Z", + "3F2504E0-4F89-11D3-9A0C-0305E82C3301", + "DanaKit", + "com.randallknutson.DanaKit", + "* 2026-10-18T12:00:00.000Z DanaKit - send rate 0.85 U/h for 30 min", + "Transmitter battery low" + ] + ) func negatives(input: String) { + #expect(Redactor(exactValues: []).redact(input) == input) + } + + @Test("Same value gets the same placeholder across inputs") func stablePlaceholders() { + let redactor = Redactor(exactValues: []) + let first = redactor.redact("a https://one.example.test b https://two.example.test") + let second = redactor.redact("c https://two.example.test d https://one.example.test") + #expect(first == "a b ") + #expect(second == "c d ") + } + + @Test("Exact values are replaced before patterns, longest first") func exactValuesFirst() { + let redactor = Redactor(exactValues: [ + Redactor.ExactValue(value: "my-site.example.test", kind: .host), + Redactor.ExactValue(value: "https://my-site.example.test/", kind: .url), + Redactor.ExactValue(value: "synthetic-secret-1", kind: .secret), + Redactor.ExactValue(value: "DANA-XYZ01", kind: .device), + Redactor.ExactValue(value: "abc", kind: .device) + ]) + let output = redactor.redact( + "GET https://my-site.example.test/api host my-site.example.test secret synthetic-secret-1 device dana-xyz01 abc" + ) + #expect(output == "GET api host secret device abc") + } + + @Test("Files are redacted in place and unknown file types are dropped") func redactsFiles() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let text = directory.appendingPathComponent("Log.txt") + let binary = directory.appendingPathComponent("Store.sqlite") + let lines = (0 ..< 20000).map { "line \($0) to https://example.test/x BG 123\n" }.joined() + try Data(lines.utf8).write(to: text) + try Data([0, 1, 2]).write(to: binary) + + try Redactor(exactValues: []).redactFiles(in: directory) + + let redacted = try String(contentsOf: text, encoding: .utf8) + #expect(!redacted.contains("example.test")) + #expect(redacted.components(separatedBy: "\n").count == 20001) + #expect(redacted.hasSuffix("line 19999 to BG 123\n")) + #expect(!FileManager.default.fileExists(atPath: binary.path)) + } +} From 20c3b6dc9131f3c64b2ebc6dce7fc2d9c6a30cdf Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:10:24 +0200 Subject: [PATCH 11/48] Simplify log export to one file per kind --- Trio.xcodeproj/project.pbxproj | 156 ++-------- Trio/Sources/Assemblies/APSAssembly.swift | 1 - Trio/Sources/Assemblies/ServiceAssembly.swift | 2 +- .../Localizations/Main/Localizable.xcstrings | 57 ++-- .../Logger/Contracts/DeviceLogExporting.swift | 12 - .../Logger/Contracts/DiagnosticArchiver.swift | 28 -- .../Contracts/DiagnosticReportSection.swift | 7 - .../Logger/Contracts/KnownLogSubsystems.swift | 24 -- Trio/Sources/Logger/Core/TrioLogger.swift | 13 + .../Logger/Export/DeviceLogExporter.swift | 133 -------- Trio/Sources/Logger/Export/LogExport.swift | 288 ++++++++++++++++++ .../Sources/Logger/Export/LogFileHeader.swift | 162 ++++++++++ .../Sources/Logger/Export/LogFileWriter.swift | 77 +++++ .../Logger/Export/LogShareAction.swift | 45 --- Trio/Sources/Logger/Export/OSLogCapture.swift | 68 ++--- .../RedactionPatterns.swift | 8 +- .../Logger/{Report => Export}/Redactor.swift | 55 ---- .../IssueReporter/SimpleLogReporter.swift | 31 -- .../Report/DiagnosticReportGenerator.swift | 269 ---------------- .../Logger/Report/DiagnosticReportView.swift | 130 -------- .../Report/Sections/AlertsSection.swift | 43 --- .../Report/Sections/AppLogSection.swift | 75 ----- .../Logger/Report/Sections/BuildSection.swift | 28 -- .../Sections/DeterminationsSection.swift | 139 --------- .../Report/Sections/DeviceInfoSection.swift | 47 --- .../Report/Sections/DeviceLogSection.swift | 45 --- .../Sections/DeviceManagersSection.swift | 45 --- .../Report/Sections/LoopTimingSection.swift | 60 ---- .../Report/Sections/NightscoutSection.swift | 59 ---- .../Report/Sections/SettingsSection.swift | 62 ---- .../Logger/Report/Sections/WatchSection.swift | 33 -- .../Logger/Store/AppLogTextExporter.swift | 98 ------ .../CGMSettings/CGMSettingsStateModel.swift | 2 +- .../CGMSettings/View/CGMRootView.swift | 16 +- .../PumpConfig/PumpConfigStateModel.swift | 2 +- .../PumpConfig/View/PumpConfigRootView.swift | 14 +- .../Modules/Settings/SettingsStateModel.swift | 36 +-- .../Settings/View/SettingsRootView.swift | 58 +--- Trio/Sources/Views/DeviceLogShareRow.swift | 103 ------- Trio/Sources/Views/LogShareRow.swift | 128 ++++++++ TrioTests/Logging/AppLogStoreTests.swift | 24 -- .../Logging/DeterminationsSectionTests.swift | 86 ------ .../Logging/DeviceLogExporterTests.swift | 182 ----------- .../Logging/DeviceLogShareRequestTests.swift | 18 -- .../Logging/DiagnosticArchiverTests.swift | 25 -- .../DiagnosticReportGeneratorTests.swift | 76 ----- TrioTests/Logging/LogExportTests.swift | 209 +++++++++++++ TrioTests/Logging/RedactorTests.swift | 24 +- 48 files changed, 993 insertions(+), 2310 deletions(-) delete mode 100644 Trio/Sources/Logger/Contracts/DeviceLogExporting.swift delete mode 100644 Trio/Sources/Logger/Contracts/DiagnosticArchiver.swift delete mode 100644 Trio/Sources/Logger/Contracts/DiagnosticReportSection.swift delete mode 100644 Trio/Sources/Logger/Contracts/KnownLogSubsystems.swift delete mode 100644 Trio/Sources/Logger/Export/DeviceLogExporter.swift create mode 100644 Trio/Sources/Logger/Export/LogExport.swift create mode 100644 Trio/Sources/Logger/Export/LogFileHeader.swift create mode 100644 Trio/Sources/Logger/Export/LogFileWriter.swift delete mode 100644 Trio/Sources/Logger/Export/LogShareAction.swift rename Trio/Sources/Logger/{Report => Export}/RedactionPatterns.swift (81%) rename Trio/Sources/Logger/{Report => Export}/Redactor.swift (53%) delete mode 100644 Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift delete mode 100644 Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift delete mode 100644 Trio/Sources/Logger/Report/DiagnosticReportView.swift delete mode 100644 Trio/Sources/Logger/Report/Sections/AlertsSection.swift delete mode 100644 Trio/Sources/Logger/Report/Sections/AppLogSection.swift delete mode 100644 Trio/Sources/Logger/Report/Sections/BuildSection.swift delete mode 100644 Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift delete mode 100644 Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift delete mode 100644 Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift delete mode 100644 Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift delete mode 100644 Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift delete mode 100644 Trio/Sources/Logger/Report/Sections/NightscoutSection.swift delete mode 100644 Trio/Sources/Logger/Report/Sections/SettingsSection.swift delete mode 100644 Trio/Sources/Logger/Report/Sections/WatchSection.swift delete mode 100644 Trio/Sources/Logger/Store/AppLogTextExporter.swift delete mode 100644 Trio/Sources/Views/DeviceLogShareRow.swift create mode 100644 Trio/Sources/Views/LogShareRow.swift delete mode 100644 TrioTests/Logging/DeterminationsSectionTests.swift delete mode 100644 TrioTests/Logging/DeviceLogExporterTests.swift delete mode 100644 TrioTests/Logging/DeviceLogShareRequestTests.swift delete mode 100644 TrioTests/Logging/DiagnosticArchiverTests.swift delete mode 100644 TrioTests/Logging/DiagnosticReportGeneratorTests.swift create mode 100644 TrioTests/Logging/LogExportTests.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index a7d92e19f..f0f05b5d7 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -12,11 +12,8 @@ 03B15F4E3077CD6100DFEBE4 /* SyaiKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 03B15F4F3077CD6100DFEBE4 /* SyaiKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 041D1E995A6AE92E9289DC49 /* TreatmentsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D1A7CA8C10C4403D4BBFA7 /* TreatmentsDataFlow.swift */; }; - 06B768620A4F3877551BF0B8 /* LoopTimingSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00DE70925909EFD810B65B94 /* LoopTimingSection.swift */; }; - 0D1EAF43EA72BAE07D16FD25 /* NightscoutSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A818A5FB069B0396BED1EC46 /* NightscoutSection.swift */; }; 0D9A5E34A899219C5C4CDFAF /* HistoryStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9455FA2D92E77A6C4AFED8A3 /* HistoryStateModel.swift */; }; 10DAA318F47843A4B894173D /* ContainerResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98546920BB7247B0AC73449A /* ContainerResolutionTests.swift */; }; - 0F2B374F0AED7ECBAD5716B1 /* DeviceLogExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BF681E9D2A9755CB9DCD62 /* DeviceLogExporter.swift */; }; 10C13662926D99DC9EDB0990 /* DeviceLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 260A8D718D2B6656B925FD08 /* DeviceLogStore.swift */; }; 110AEDE32C5193D200615CC9 /* BolusIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE02C5193D100615CC9 /* BolusIntent.swift */; }; 110AEDE42C5193D200615CC9 /* BolusIntentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE12C5193D100615CC9 /* BolusIntentRequest.swift */; }; @@ -62,12 +59,9 @@ 19F95FFA29F1102A00314DDC /* StatRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19F95FF929F1102A00314DDC /* StatRootView.swift */; }; 1BBB001DAD60F3B8CEA4B1C7 /* ISFEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505E09DC17A0C3D0AF4B66FE /* ISFEditorStateModel.swift */; }; 1D845DF2E3324130E1D95E67 /* HistoryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60744C3E9BB3652895C908CC /* HistoryProvider.swift */; }; - 1F3B64442195D252B5D5C972 /* AppLogTextExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB6B0839018B69B643F2D2C /* AppLogTextExporter.swift */; }; 23888883D4EA091C88480FF2 /* TreatmentsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19984D62EFC0035A9E9644D /* TreatmentsProvider.swift */; }; 25726254CCC5496EB7BD95C8 /* CGMSensorStateLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41BEC89F0411CF3EF0F6A61B /* CGMSensorStateLog.swift */; }; 27DE1FE3427339F87A4D1892 /* LaunchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24AF0AA0328EA5E4016BD4D6 /* LaunchID.swift */; }; - 281F422373124952B572A174 /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65598E41151729F57FA71BFC /* SettingsSection.swift */; }; - 2B205BE3D298C1265738E386 /* DeterminationsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEB7B02BB1F3747D8780E71 /* DeterminationsSection.swift */; }; 2C9535979A6E92EE0EC83255 /* RedactorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 704720C410E0C4F7D10B184B /* RedactorTests.swift */; }; 2D5DFD6C90037128EA3DF5A9 /* DeviceLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161D44BBD9BB82CE0AF6B4EC /* DeviceLogStoreTests.swift */; }; 320D030F724170A637F06D50 /* (null) in Sources */ = {isa = PBXBuildFile; }; @@ -110,7 +104,6 @@ 38192E04261B82FA0094D973 /* ReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38192E03261B82FA0094D973 /* ReachabilityManager.swift */; }; 38192E07261BA9960094D973 /* FetchTreatmentsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38192E06261BA9960094D973 /* FetchTreatmentsManager.swift */; }; 38192E0D261BAF980094D973 /* ConvenienceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38192E0C261BAF980094D973 /* ConvenienceExtensions.swift */; }; - 38205C536D1AB42976E6834C /* DeviceManagersSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6723682F37BD66333DBAEEC3 /* DeviceManagersSection.swift */; }; 3821ED4C25DD18BA00BC42AD /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3821ED4B25DD18BA00BC42AD /* Constants.swift */; }; 382C133725F13A1E00715CE1 /* InsulinSensitivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382C133625F13A1E00715CE1 /* InsulinSensitivities.swift */; }; 382C134B25F14E3700715CE1 /* BGTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382C134A25F14E3700715CE1 /* BGTargets.swift */; }; @@ -152,31 +145,22 @@ 38AEE73D25F0200C0013F05B /* TrioSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE73C25F0200C0013F05B /* TrioSettings.swift */; }; 38AEE75225F022080013F05B /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75125F022080013F05B /* SettingsManager.swift */; }; 38AEE75725F0F18E0013F05B /* CarbsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */; }; - 3B4D3E4C9C67D7C82D253132 /* LogShareAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D25A78012C06C13F61394EEB /* LogShareAction.swift */; }; + 38B4E1780D264CEBEB5DBCB3 /* LogExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF80D3953C10DCDF5D80C27 /* LogExportTests.swift */; }; 4ABD323927D718C0177C26B5 /* OSLogCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */; }; 4E3B6212E43A3E90DD735B30 /* AppLog.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */; }; 536B4DB89FCFE9188F4DFE5D /* RedactionPatterns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCB6F0602E069F5B4F83EDC /* RedactionPatterns.swift */; }; 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */; }; - 60886ABC93CDB0D0EF6F7660 /* DeviceLogExporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0AE7DD84FC1252EAADC35F1 /* DeviceLogExporting.swift */; }; - 6ED6CA7C8BB78F440C2DDE04 /* BuildSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39C1F3299C2A3903B601D11 /* BuildSection.swift */; }; 7BA97B74F25D6A1F5D853EFB /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F602845F4994CA0A57286EE6 /* ShareSheet.swift */; }; - 7D9C485831D451703B20F283 /* DeviceLogShareRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FABAFA208B56BAA140521720 /* DeviceLogShareRequestTests.swift */; }; - 8C9B2B65B1FF556ADF8915DF /* WatchSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AA5756BADDE99F52317A6D /* WatchSection.swift */; }; 91AE5F35FC765DFE4C1A2589 /* AppLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */; }; 91FC2FB975FB723C55744FB1 /* LogSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB5F206A024F911C4E145D5 /* LogSink.swift */; }; 931AD2C542093B9D1A342065 /* InMemoryLogSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C171A8FC35B730F1FFF25F /* InMemoryLogSink.swift */; }; + 96310848E7A3468607C340E9 /* LogExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DB8B37A782E6D75420DB2FC /* LogExport.swift */; }; A8354E8A31A4F482D0B649F1 /* Redactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0396B90CF23D28D2FDCD78 /* Redactor.swift */; }; - B797B55C99BEFD9F9433C576 /* AppLogSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D41475BAFA8F8CAFC04C5C /* AppLogSection.swift */; }; - BA411C5352750DB69633D080 /* DeviceLogSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E87AC5818D1F50CB16CAAB /* DeviceLogSection.swift */; }; + B0D5791570373A23CD562E97 /* LogFileHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FBFFB8EF42D847AFFDA6C05 /* LogFileHeader.swift */; }; BD14CA4C88D904242178280A /* TrioLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7BC1040D2F0BD84E0718784 /* TrioLoggerTests.swift */; }; - BDAEE0006F95672099C25715 /* DiagnosticReportGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BA4CCF9E04B65D2624F103 /* DiagnosticReportGeneratorTests.swift */; }; - BDBAB3961A62E28A7C395887 /* DiagnosticArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CCD5DC2DBA49CD10579B99 /* DiagnosticArchiver.swift */; }; - C2E1829091B5A504901C6AB6 /* DeviceLogExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F42FDD4C077138ADA03C3CA /* DeviceLogExporterTests.swift */; }; - CAB5A1B2811F53317FFB4CD8 /* DiagnosticReportGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614C56186C5B8C3C99C57CE6 /* DiagnosticReportGenerator.swift */; }; - D3FE496564A6200CB6AE5EFA /* DiagnosticArchiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13038DD772B631A95414F658 /* DiagnosticArchiverTests.swift */; }; + BF0FA8AAE98B4080B961F69F /* LogFileWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 366F24AC292DBC7BC55CF196 /* LogFileWriter.swift */; }; DE6F63D93662C12AD18A3802 /* LogRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F0205BEC3117B5D00022C30 /* LogRecord.swift */; }; E0F5C5534791F6BB9A237891 /* TrioLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69EF64E9534E4822199EA279 /* TrioLogger.swift */; }; - E5AC09ECB66AE8FA62A00878 /* AlertsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41E35FA1915BD201B4F7A0A2 /* AlertsSection.swift */; }; E73259BE7A7D1AFB93D6B8DF /* CarbEntryMutationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2F107674CF1D9C0A2A9ACF /* CarbEntryMutationService.swift */; }; 38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */ = {isa = PBXBuildFile; productRef = 38B17B6525DD90E0005CAE3D /* SwiftDate */; }; 38B4F3AF25E2979F00E76A18 /* IndexedCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B4F3AE25E2979F00E76A18 /* IndexedCollection.swift */; }; @@ -220,7 +204,6 @@ 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 */; }; @@ -928,12 +911,8 @@ DD30BA1A2E08AB9F00DA677C /* CarbImpactParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */; }; DD32CF982CC82463003686D6 /* TrioRemoteControl+Bolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */; }; DD32CF9A2CC8247B003686D6 /* TrioRemoteControl+Meal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */; }; - EABED858C84BEB209F8A7076 /* DiagnosticReportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 633DDEAA939410C6120B9934 /* DiagnosticReportSection.swift */; }; F0D6F2237EB1FB4862D0D4D7 /* AppLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39062D177D83C69CAFC9B2AC /* AppLogStore.swift */; }; - F60FCBDC5FB1457995D444EC /* KnownLogSubsystems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426EFF43AC38AB621583B2BC /* KnownLogSubsystems.swift */; }; - F636A48B266E10A9624A626F /* DiagnosticReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC672185C6C74E47114A2373 /* DiagnosticReportView.swift */; }; - F6854A961CB1CF1105757936 /* DeviceLogShareRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EB8FEFF04CF8327EE83D84 /* DeviceLogShareRow.swift */; }; - F6AB59B482B06C651B5DD91E /* DeterminationsSectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA4E33A9F1EA8D5126887CE /* DeterminationsSectionTests.swift */; }; + F6854A961CB1CF1105757936 /* LogShareRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EB8FEFF04CF8327EE83D84 /* LogShareRow.swift */; }; FC83BDFB7C330BFD6A4DECFC /* WatchLogIngestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */; }; FF0425EA9C0AFF6A6DF0B10D /* TrioRemoteControl+MealMutation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0480CC863D1BBC71668F34B3 /* TrioRemoteControl+MealMutation.swift */; }; DD32CF9C2CC82499003686D6 /* TrioRemoteControl+TempTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF9B2CC82495003686D6 /* TrioRemoteControl+TempTarget.swift */; }; @@ -1132,7 +1111,6 @@ 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 */; }; - FF66A0A70765482014FBEF58 /* DeviceInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D37920C6EBE47C8404A7AF34 /* DeviceInfoSection.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1250,7 +1228,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 00DE70925909EFD810B65B94 /* LoopTimingSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopTimingSection.swift; sourceTree = ""; }; 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0A1E8522E7684C6AA742F6E4 /* HomeStateModel+QuickPickTreatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeStateModel+QuickPickTreatments.swift"; sourceTree = ""; }; @@ -1267,11 +1244,8 @@ 118DF7652C5ECBC60067FEB7 /* CancelOverrideIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CancelOverrideIntent.swift; sourceTree = ""; }; 118DF7672C5ECBC60067FEB7 /* OverridePresetEntity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverridePresetEntity.swift; sourceTree = ""; }; 118DF7682C5ECBC60067FEB7 /* OverridePresetsIntentRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverridePresetsIntentRequest.swift; sourceTree = ""; }; - 13038DD772B631A95414F658 /* DiagnosticArchiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticArchiverTests.swift; sourceTree = ""; }; 1526A587AD1544C495050662 /* QuickPickTreatmentsInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickTreatmentsInfoView.swift; sourceTree = ""; }; 161D44BBD9BB82CE0AF6B4EC /* DeviceLogStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogStoreTests.swift; sourceTree = ""; }; - 16AA5756BADDE99F52317A6D /* WatchSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSection.swift; sourceTree = ""; }; - 17CCD5DC2DBA49CD10579B99 /* DiagnosticArchiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticArchiver.swift; sourceTree = ""; }; 19012CDB291D2CB900FB8210 /* LoopStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStats.swift; sourceTree = ""; }; 190EBCC329FF136900BA767D /* UserInterfaceSettingsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInterfaceSettingsDataFlow.swift; sourceTree = ""; }; 190EBCC529FF138000BA767D /* UserInterfaceSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInterfaceSettingsProvider.swift; sourceTree = ""; }; @@ -1311,6 +1285,7 @@ 2AD22C985B79A2F0D2EA3D9D /* PumpConfigRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigRootView.swift; sourceTree = ""; }; 2EEC5C2927FA4C5DA61A1A40 /* QuickPickTreatmentsConfigProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickTreatmentsConfigProvider.swift; sourceTree = ""; }; 2F2A13DF0EDEEEDC4106AA2A /* NightscoutConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigDataFlow.swift; sourceTree = ""; }; + 366F24AC292DBC7BC55CF196 /* LogFileWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFileWriter.swift; sourceTree = ""; }; 36F58DDD71F0E795464FA3F0 /* TargetsEditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorStateModel.swift; sourceTree = ""; }; 3811DE0725C9D32E00A708ED /* BaseView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseView.swift; sourceTree = ""; }; 3811DE0825C9D32F00A708ED /* BaseProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseProvider.swift; sourceTree = ""; }; @@ -1448,7 +1423,6 @@ 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 = ""; }; @@ -1566,14 +1540,10 @@ 3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = ""; }; 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = ""; }; 41BEC89F0411CF3EF0F6A61B /* CGMSensorStateLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMSensorStateLog.swift; sourceTree = ""; }; - 41E35FA1915BD201B4F7A0A2 /* AlertsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsSection.swift; sourceTree = ""; }; 42369F66CF91F30624C0B3A6 /* BasalProfileEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorProvider.swift; sourceTree = ""; }; - 426EFF43AC38AB621583B2BC /* KnownLogSubsystems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnownLogSubsystems.swift; sourceTree = ""; }; - 43BF681E9D2A9755CB9DCD62 /* DeviceLogExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogExporter.swift; sourceTree = ""; }; 44080E4709E3AE4B73054563 /* ConfigEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorProvider.swift; sourceTree = ""; }; 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCapture.swift; sourceTree = ""; }; 48B83503461B4F8D97B30115 /* SettingsSearchHighlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchHighlight.swift; sourceTree = ""; }; - 48E87AC5818D1F50CB16CAAB /* DeviceLogSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogSection.swift; sourceTree = ""; }; 49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarminWatchSettings.swift; sourceTree = ""; }; 491D6FB92D56741C00C49F67 /* TempTargetRunStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetRunStored+CoreDataClass.swift"; sourceTree = ""; }; 491D6FBA2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetRunStored+CoreDataProperties.swift"; sourceTree = ""; }; @@ -1627,16 +1597,11 @@ 5E12FE0E2EBC0000000000A1 /* LibreLoop.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LibreLoop.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5E12FE0E2EBC0000000000A2 /* LibreLoopUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LibreLoopUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 60744C3E9BB3652895C908CC /* HistoryProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryProvider.swift; sourceTree = ""; }; - 614C56186C5B8C3C99C57CE6 /* DiagnosticReportGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticReportGenerator.swift; sourceTree = ""; }; - 633DDEAA939410C6120B9934 /* DiagnosticReportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticReportSection.swift; sourceTree = ""; }; 64AA5E04A2761F6EEA6568E1 /* CarbRatioEditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarbRatioEditorStateModel.swift; sourceTree = ""; }; 65070A322BFDCB83006F213F /* TidepoolStartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolStartView.swift; sourceTree = ""; }; - 65598E41151729F57FA71BFC /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; - 6723682F37BD66333DBAEEC3 /* DeviceManagersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagersSection.swift; sourceTree = ""; }; 67F94DD2853CF42BA4E30616 /* BasalProfileEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorDataFlow.swift; sourceTree = ""; }; 69EF64E9534E4822199EA279 /* TrioLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioLogger.swift; sourceTree = ""; }; 6AB077E2E3EC75CCBDEFF8B5 /* AppLog.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AppLog.xcdatamodel; sourceTree = ""; }; - 6AB6B0839018B69B643F2D2C /* AppLogTextExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogTextExporter.swift; sourceTree = ""; }; 6B1A8D012B14D88B00E76752 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; 6B1A8D172B14D91600E76752 /* LiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 6B1A8D182B14D91600E76752 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -1647,17 +1612,13 @@ 6B1A8D252B14D91700E76752 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6B1A8D2D2B156EEF00E76752 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; 6BCF84DC2B16843A003AD46E /* LiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityAttributes.swift; sourceTree = ""; }; - 6F42FDD4C077138ADA03C3CA /* DeviceLogExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogExporterTests.swift; sourceTree = ""; }; 704720C410E0C4F7D10B184B /* RedactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactorTests.swift; sourceTree = ""; }; - 7AA4E33A9F1EA8D5126887CE /* DeterminationsSectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationsSectionTests.swift; sourceTree = ""; }; + 7DB8B37A782E6D75420DB2FC /* LogExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExport.swift; sourceTree = ""; }; 7E2CF3BABA4BF1C48CC96FD7 /* LiveActivityGlucoseStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityGlucoseStyle.swift; sourceTree = ""; }; - 9FEB7B02BB1F3747D8780E71 /* DeterminationsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationsSection.swift; sourceTree = ""; }; - A39C1F3299C2A3903B601D11 /* BuildSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildSection.swift; sourceTree = ""; }; - A818A5FB069B0396BED1EC46 /* NightscoutSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSection.swift; sourceTree = ""; }; + 7FBFFB8EF42D847AFFDA6C05 /* LogFileHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFileHeader.swift; sourceTree = ""; }; ABB5F206A024F911C4E145D5 /* LogSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogSink.swift; sourceTree = ""; }; - B7D41475BAFA8F8CAFC04C5C /* AppLogSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogSection.swift; sourceTree = ""; }; - B8BA4CCF9E04B65D2624F103 /* DiagnosticReportGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticReportGeneratorTests.swift; sourceTree = ""; }; C7BC1040D2F0BD84E0718784 /* TrioLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioLoggerTests.swift; sourceTree = ""; }; + CAF80D3953C10DCDF5D80C27 /* LogExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExportTests.swift; sourceTree = ""; }; CD6672C97F2AB74125F46DD5 /* LiveActivitySimpleWidgetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySimpleWidgetConfiguration.swift; sourceTree = ""; }; 71D44AAA2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertPermissionsChecker.swift; sourceTree = ""; }; 79BDA519C9B890FD9A5DFCF3 /* ISFEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorDataFlow.swift; sourceTree = ""; }; @@ -2086,8 +2047,6 @@ CEE9A65B2BBB41C800EB5194 /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.swift; sourceTree = ""; }; CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationsTests.swift; sourceTree = ""; }; D0BDC6993C1087310EDFC428 /* CarbRatioEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarbRatioEditorRootView.swift; sourceTree = ""; }; - D25A78012C06C13F61394EEB /* LogShareAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogShareAction.swift; sourceTree = ""; }; - D37920C6EBE47C8404A7AF34 /* DeviceInfoSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoSection.swift; sourceTree = ""; }; D543DE17E0054AF0821411BB /* QuickPickTreatmentsConfigDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickTreatmentsConfigDataFlow.swift; sourceTree = ""; }; D7C5E1F4A7B91E2F3C5D6E71 /* TimeInterval+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Convenience.swift"; sourceTree = ""; }; DB0BA0010000000000000002 /* DevelopmentBranchAlerter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevelopmentBranchAlerter.swift; sourceTree = ""; }; @@ -2324,7 +2283,6 @@ E00EEC0227368630002FF094 /* NetworkAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkAssembly.swift; sourceTree = ""; }; E013D871273AC6FE0014109C /* GlucoseSimulatorSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSimulatorSource.swift; sourceTree = ""; }; E06B9119275B5EEA003C04B6 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; - E0AE7DD84FC1252EAADC35F1 /* DeviceLogExporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogExporting.swift; sourceTree = ""; }; E0CC2C5B275B9DAE00A7BC71 /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = System/Library/Frameworks/HealthKit.framework; sourceTree = SDKROOT; }; E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSample.swift; sourceTree = ""; }; E592A36F2CEEC01E009A472C /* ContactTrickEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickEntry.swift; sourceTree = ""; }; @@ -2334,7 +2292,7 @@ E592A3752CEEC038009A472C /* ContactImageStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageStateModel.swift; sourceTree = ""; }; E9C171A8FC35B730F1FFF25F /* InMemoryLogSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryLogSink.swift; sourceTree = ""; }; F602845F4994CA0A57286EE6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; - F7EB8FEFF04CF8327EE83D84 /* DeviceLogShareRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogShareRow.swift; sourceTree = ""; }; + F7EB8FEFF04CF8327EE83D84 /* LogShareRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogShareRow.swift; sourceTree = ""; }; F816825D28DB441200054060 /* HeartBeatManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartBeatManager.swift; sourceTree = ""; }; F816825F28DB441800054060 /* BluetoothTransmitter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothTransmitter.swift; sourceTree = ""; }; F90692A9274B7AAE0037068D /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = ""; }; @@ -2347,9 +2305,7 @@ FAB1E00000000000000000B3 /* ParityScenarios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityScenarios.swift; sourceTree = ""; }; FAB1E00000000000000000B4 /* ParityGoldenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityGoldenTests.swift; sourceTree = ""; }; FAB1E00000000000000000B5 /* ParityPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParityPerformanceTests.swift; sourceTree = ""; }; - FABAFA208B56BAA140521720 /* DeviceLogShareRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogShareRequestTests.swift; sourceTree = ""; }; FBB3BAE7494CB771ABAC7B8B /* ISFEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorRootView.swift; sourceTree = ""; }; - FC672185C6C74E47114A2373 /* DiagnosticReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticReportView.swift; sourceTree = ""; }; FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutPreferences.swift; sourceTree = ""; }; FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = ""; }; @@ -3220,7 +3176,7 @@ 7A11AC0DE000000000000170 /* GlassActionSheet.swift */, 7A11AC0DE000000000000014 /* ManualGlucoseEntryView.swift */, F602845F4994CA0A57286EE6 /* ShareSheet.swift */, - F7EB8FEFF04CF8327EE83D84 /* DeviceLogShareRow.swift */, + F7EB8FEFF04CF8327EE83D84 /* LogShareRow.swift */, ); path = Views; sourceTree = ""; @@ -3508,25 +3464,15 @@ isa = PBXGroup; children = ( 38E98A1C25F52C9300C0CED0 /* Logger.swift */, - 38E98A1D25F52C9300C0CED0 /* IssueReporter */, 79A6FC1583EE7761756C86BD /* Contracts */, 9F14EBDAAE691A7D9C7913DE /* Core */, B57248ACB19D6554EBCAA15E /* DeviceLog */, 9EFA1E4C7EB7203F0D39B585 /* Export */, 5BA8CACAF5028913393A78CB /* Store */, - 56A0C4CEFE19625056490BB2 /* Report */, ); path = Logger; sourceTree = ""; }; - 38E98A1D25F52C9300C0CED0 /* IssueReporter */ = { - isa = PBXGroup; - children = ( - 38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */, - ); - path = IssueReporter; - sourceTree = ""; - }; 38E98A3225F5300800C0CED0 /* Config */ = { isa = PBXGroup; children = ( @@ -3807,18 +3753,6 @@ path = View; sourceTree = ""; }; - 56A0C4CEFE19625056490BB2 /* Report */ = { - isa = PBXGroup; - children = ( - 9AAF3F0F84429E86D5AC0E3B /* Sections */, - 614C56186C5B8C3C99C57CE6 /* DiagnosticReportGenerator.swift */, - 3B0396B90CF23D28D2FDCD78 /* Redactor.swift */, - 0CCB6F0602E069F5B4F83EDC /* RedactionPatterns.swift */, - FC672185C6C74E47114A2373 /* DiagnosticReportView.swift */, - ); - path = Report; - sourceTree = ""; - }; 5825D1622BD405AE00F36E9B /* Helper */ = { isa = PBXGroup; children = ( @@ -3881,7 +3815,6 @@ isa = PBXGroup; children = ( 39062D177D83C69CAFC9B2AC /* AppLogStore.swift */, - 6AB6B0839018B69B643F2D2C /* AppLogTextExporter.swift */, 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */, ); path = Store; @@ -3929,10 +3862,6 @@ 0F0205BEC3117B5D00022C30 /* LogRecord.swift */, ABB5F206A024F911C4E145D5 /* LogSink.swift */, 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */, - E0AE7DD84FC1252EAADC35F1 /* DeviceLogExporting.swift */, - 633DDEAA939410C6120B9934 /* DiagnosticReportSection.swift */, - 17CCD5DC2DBA49CD10579B99 /* DiagnosticArchiver.swift */, - 426EFF43AC38AB621583B2BC /* KnownLogSubsystems.swift */, ); path = Contracts; sourceTree = ""; @@ -3948,24 +3877,6 @@ path = PumpConfig; sourceTree = ""; }; - 9AAF3F0F84429E86D5AC0E3B /* Sections */ = { - isa = PBXGroup; - children = ( - A39C1F3299C2A3903B601D11 /* BuildSection.swift */, - D37920C6EBE47C8404A7AF34 /* DeviceInfoSection.swift */, - 65598E41151729F57FA71BFC /* SettingsSection.swift */, - 6723682F37BD66333DBAEEC3 /* DeviceManagersSection.swift */, - 41E35FA1915BD201B4F7A0A2 /* AlertsSection.swift */, - 9FEB7B02BB1F3747D8780E71 /* DeterminationsSection.swift */, - 00DE70925909EFD810B65B94 /* LoopTimingSection.swift */, - A818A5FB069B0396BED1EC46 /* NightscoutSection.swift */, - 16AA5756BADDE99F52317A6D /* WatchSection.swift */, - B7D41475BAFA8F8CAFC04C5C /* AppLogSection.swift */, - 48E87AC5818D1F50CB16CAAB /* DeviceLogSection.swift */, - ); - path = Sections; - sourceTree = ""; - }; 9E56E3626FAD933385101B76 /* History */ = { isa = PBXGroup; children = ( @@ -3983,9 +3894,12 @@ 9EFA1E4C7EB7203F0D39B585 /* Export */ = { isa = PBXGroup; children = ( - 43BF681E9D2A9755CB9DCD62 /* DeviceLogExporter.swift */, 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */, - D25A78012C06C13F61394EEB /* LogShareAction.swift */, + 7DB8B37A782E6D75420DB2FC /* LogExport.swift */, + 366F24AC292DBC7BC55CF196 /* LogFileWriter.swift */, + 7FBFFB8EF42D847AFFDA6C05 /* LogFileHeader.swift */, + 3B0396B90CF23D28D2FDCD78 /* Redactor.swift */, + 0CCB6F0602E069F5B4F83EDC /* RedactionPatterns.swift */, ); path = Export; sourceTree = ""; @@ -4014,16 +3928,12 @@ A5D6B6D83F32CE8BBDDB4E5B /* Logging */ = { isa = PBXGroup; children = ( - 13038DD772B631A95414F658 /* DiagnosticArchiverTests.swift */, 161D44BBD9BB82CE0AF6B4EC /* DeviceLogStoreTests.swift */, - 6F42FDD4C077138ADA03C3CA /* DeviceLogExporterTests.swift */, C7BC1040D2F0BD84E0718784 /* TrioLoggerTests.swift */, 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */, 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */, - B8BA4CCF9E04B65D2624F103 /* DiagnosticReportGeneratorTests.swift */, - 7AA4E33A9F1EA8D5126887CE /* DeterminationsSectionTests.swift */, 704720C410E0C4F7D10B184B /* RedactorTests.swift */, - FABAFA208B56BAA140521720 /* DeviceLogShareRequestTests.swift */, + CAF80D3953C10DCDF5D80C27 /* LogExportTests.swift */, ); path = Logging; sourceTree = ""; @@ -6014,7 +5924,6 @@ 7A11AC0DE00000000000010E /* HomeRootView+MealPanel.swift in Sources */, 7A11AC0DE00000000000010D /* HomeRootView+Header.swift in Sources */, 3E62C7822F54CC1B00433237 /* BolusDisplayThreshold.swift in Sources */, - 38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */, 3811DE6125C9D4D500A708ED /* ViewModifiers.swift in Sources */, 3811DEAC25C9D88300A708ED /* NightscoutManager.swift in Sources */, BD793CB22CE8033500D669AC /* TempTargetRunStored.swift in Sources */, @@ -6484,37 +6393,20 @@ DE6F63D93662C12AD18A3802 /* LogRecord.swift in Sources */, 91FC2FB975FB723C55744FB1 /* LogSink.swift in Sources */, 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */, - 60886ABC93CDB0D0EF6F7660 /* DeviceLogExporting.swift in Sources */, - EABED858C84BEB209F8A7076 /* DiagnosticReportSection.swift in Sources */, - BDBAB3961A62E28A7C395887 /* DiagnosticArchiver.swift in Sources */, - F60FCBDC5FB1457995D444EC /* KnownLogSubsystems.swift in Sources */, E0F5C5534791F6BB9A237891 /* TrioLogger.swift in Sources */, 931AD2C542093B9D1A342065 /* InMemoryLogSink.swift in Sources */, 27DE1FE3427339F87A4D1892 /* LaunchID.swift in Sources */, 10C13662926D99DC9EDB0990 /* DeviceLogStore.swift in Sources */, - 0F2B374F0AED7ECBAD5716B1 /* DeviceLogExporter.swift in Sources */, 4ABD323927D718C0177C26B5 /* OSLogCapture.swift in Sources */, - 3B4D3E4C9C67D7C82D253132 /* LogShareAction.swift in Sources */, F0D6F2237EB1FB4862D0D4D7 /* AppLogStore.swift in Sources */, - 1F3B64442195D252B5D5C972 /* AppLogTextExporter.swift in Sources */, - CAB5A1B2811F53317FFB4CD8 /* DiagnosticReportGenerator.swift in Sources */, A8354E8A31A4F482D0B649F1 /* Redactor.swift in Sources */, 536B4DB89FCFE9188F4DFE5D /* RedactionPatterns.swift in Sources */, - F636A48B266E10A9624A626F /* DiagnosticReportView.swift in Sources */, - 6ED6CA7C8BB78F440C2DDE04 /* BuildSection.swift in Sources */, - FF66A0A70765482014FBEF58 /* DeviceInfoSection.swift in Sources */, - 281F422373124952B572A174 /* SettingsSection.swift in Sources */, - 38205C536D1AB42976E6834C /* DeviceManagersSection.swift in Sources */, - E5AC09ECB66AE8FA62A00878 /* AlertsSection.swift in Sources */, - 2B205BE3D298C1265738E386 /* DeterminationsSection.swift in Sources */, - 06B768620A4F3877551BF0B8 /* LoopTimingSection.swift in Sources */, - 0D1EAF43EA72BAE07D16FD25 /* NightscoutSection.swift in Sources */, - 8C9B2B65B1FF556ADF8915DF /* WatchSection.swift in Sources */, - B797B55C99BEFD9F9433C576 /* AppLogSection.swift in Sources */, - BA411C5352750DB69633D080 /* DeviceLogSection.swift in Sources */, 7BA97B74F25D6A1F5D853EFB /* ShareSheet.swift in Sources */, - F6854A961CB1CF1105757936 /* DeviceLogShareRow.swift in Sources */, 4E3B6212E43A3E90DD735B30 /* AppLog.xcdatamodeld in Sources */, + B0D5791570373A23CD562E97 /* LogFileHeader.swift in Sources */, + BF0FA8AAE98B4080B961F69F /* LogFileWriter.swift in Sources */, + 96310848E7A3468607C340E9 /* LogExport.swift in Sources */, + F6854A961CB1CF1105757936 /* LogShareRow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6627,16 +6519,12 @@ CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */, CD1235B000000000000000F2 /* TidepoolUploadSerializerTests.swift in Sources */, CE1425B000000000000000A2 /* CarbsEntryLoopKitTests.swift in Sources */, - D3FE496564A6200CB6AE5EFA /* DiagnosticArchiverTests.swift in Sources */, 2D5DFD6C90037128EA3DF5A9 /* DeviceLogStoreTests.swift in Sources */, - C2E1829091B5A504901C6AB6 /* DeviceLogExporterTests.swift in Sources */, BD14CA4C88D904242178280A /* TrioLoggerTests.swift in Sources */, 91AE5F35FC765DFE4C1A2589 /* AppLogStoreTests.swift in Sources */, FC83BDFB7C330BFD6A4DECFC /* WatchLogIngestTests.swift in Sources */, - BDAEE0006F95672099C25715 /* DiagnosticReportGeneratorTests.swift in Sources */, - F6AB59B482B06C651B5DD91E /* DeterminationsSectionTests.swift in Sources */, 2C9535979A6E92EE0EC83255 /* RedactorTests.swift in Sources */, - 7D9C485831D451703B20F283 /* DeviceLogShareRequestTests.swift in Sources */, + 38B4E1780D264CEBEB5DBCB3 /* LogExportTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Trio/Sources/Assemblies/APSAssembly.swift b/Trio/Sources/Assemblies/APSAssembly.swift index 372d031bf..cee1766f2 100644 --- a/Trio/Sources/Assemblies/APSAssembly.swift +++ b/Trio/Sources/Assemblies/APSAssembly.swift @@ -4,7 +4,6 @@ import Swinject final class APSAssembly: Assembly { func assemble(container: Container) { container.register(DeviceLogStore.self) { _ in DeviceLogStore() }.inObjectScope(.container) - container.register(DeviceLogExporting.self) { r in DeviceLogExporter(resolver: r) } 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 2f6c02037..a6a9f4fe6 100644 --- a/Trio/Sources/Assemblies/ServiceAssembly.swift +++ b/Trio/Sources/Assemblies/ServiceAssembly.swift @@ -38,6 +38,6 @@ final class ServiceAssembly: Assembly { .inObjectScope(.container) container.register(TelemetryAttestor.self) { r in TelemetryAttestor(resolver: r) } .inObjectScope(.container) - container.register(DiagnosticReportGenerator.self) { r in DiagnosticReportGenerator(resolver: r) } + container.register(LogExport.self) { r in LogExport(resolver: r) } } } diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index a497541ee..4a0b7126d 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1088,9 +1088,6 @@ } } } - }, - "1 day" : { - }, "3 days" : { @@ -13316,9 +13313,6 @@ } } } - }, - "%lld days" : { - }, "%lld g" : { "localizations" : { @@ -42225,6 +42219,9 @@ } } } + }, + "All Logs" : { + }, "All Past Days of Data " : { "comment" : "Duration displayed in statPanel", @@ -49883,9 +49880,6 @@ } } } - }, - "App logs" : { - }, "App version, build date, branch, and commit SHA; whether this is a TestFlight build; an Apple-supplied per-vendor identifier (IDFV) and a per-install UUID; hardware identifier (e.g. iPhone15,2); platform and iOS version; the paired pump model (when a pump is configured); the paired CGM type and model (when a CGM is configured); whether Nightscout, Tidepool, and Apple Health are configured (yes/no — no URLs, tokens, or credentials); and a few preference flags (units, closed-loop on/off, Live Activity, calendar integration). A rolling cold-launch count is included. The full JSON is visible under App Diagnostics → What's sent." : { "localizations" : { @@ -76505,6 +76499,9 @@ } } } + }, + "CGM Logs" : { + }, "CGM noise level too high: %lld." : { "localizations" : { @@ -89954,9 +89951,6 @@ } } } - }, - "Copy Markdown" : { - }, "Copy Secret" : { "localizations" : { @@ -91537,6 +91531,9 @@ } } } + }, + "Creates one file you can send when asking for help." : { + }, "Critical" : { "comment" : "Name of the critical alarm sound.", @@ -110363,9 +110360,6 @@ } } } - }, - "Device logs" : { - }, "Devices" : { "comment" : "Devices menu item in the Settings main view.", @@ -110765,12 +110759,6 @@ } } } - }, - "Diagnostic Report" : { - - }, - "Diagnostic report" : { - }, "Dings" : { "comment" : "Name of a sound file.", @@ -163375,6 +163363,9 @@ } } } + }, + "How far back?" : { + }, "How it Works" : { "localizations" : { @@ -164707,9 +164698,6 @@ } } } - }, - "Identifiers such as URLs, secrets, and device serials are replaced before sharing." : { - }, "If \"Display IOB and COB\" is also enabled, \"IOB\" and \"COB\" will be replaced with the following emojis:" : { "localizations" : { @@ -170112,9 +170100,6 @@ } } } - }, - "Include debug lines" : { - }, "Include IOB & COB in the calendar event data." : { "localizations" : { @@ -170245,9 +170230,6 @@ } } } - }, - "Includes everything this device reported to Trio in the last 3 days. Low-level Bluetooth traces cover the current app session only." : { - }, "Increase sensitivity when glucose is above target if a manual Temp Target > %@ %@ is set." : { "localizations" : { @@ -178301,6 +178283,9 @@ }, "Last 24 Hours" : { "comment" : "Home stats panel range option" + }, + "Last 3 Days" : { + }, "last 3 months" : { "comment" : "Stats banner scope" @@ -243747,6 +243732,9 @@ } } } + }, + "Pump Logs" : { + }, "Pump Model" : { "localizations" : { @@ -268699,9 +268687,6 @@ }, "Share Pump Logs" : { - }, - "Share Report" : { - }, "Shared Secret" : { "localizations" : { @@ -312336,9 +312321,6 @@ } } } - }, - "Time window" : { - }, "Time-Sensitive" : { "localizations" : { @@ -350799,6 +350781,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/DeviceLogExporting.swift b/Trio/Sources/Logger/Contracts/DeviceLogExporting.swift deleted file mode 100644 index 81139b2aa..000000000 --- a/Trio/Sources/Logger/Contracts/DeviceLogExporting.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -struct DeviceLogExportRequest { - var window: DateInterval - var managerIdentifier: String? - var includeOSLog: Bool - var includeKitFiles: Bool -} - -protocol DeviceLogExporting { - func export(_ request: DeviceLogExportRequest, into directory: URL) async throws -} diff --git a/Trio/Sources/Logger/Contracts/DiagnosticArchiver.swift b/Trio/Sources/Logger/Contracts/DiagnosticArchiver.swift deleted file mode 100644 index 66f0e1c42..000000000 --- a/Trio/Sources/Logger/Contracts/DiagnosticArchiver.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -enum DiagnosticArchiver { - static func zip(_ directory: URL, name: String) throws -> URL { - let fileManager = FileManager.default - let destination = fileManager.temporaryDirectory.appendingPathComponent("\(name).zip") - var coordinationError: NSError? - var copyError: Error? - - NSFileCoordinator().coordinate(readingItemAt: directory, options: .forUploading, error: &coordinationError) { zipURL in - do { - if fileManager.fileExists(atPath: destination.path) { - try fileManager.removeItem(at: destination) - } - try fileManager.copyItem(at: zipURL, to: destination) - } catch { - copyError = error - } - } - - if let coordinationError { throw coordinationError } - if let copyError { throw copyError } - guard fileManager.fileExists(atPath: destination.path) else { - throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: destination.path]) - } - return destination - } -} diff --git a/Trio/Sources/Logger/Contracts/DiagnosticReportSection.swift b/Trio/Sources/Logger/Contracts/DiagnosticReportSection.swift deleted file mode 100644 index a1dc34293..000000000 --- a/Trio/Sources/Logger/Contracts/DiagnosticReportSection.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -protocol DiagnosticReportSection { - var title: String { get } - var order: Int { get } - func render(window: DateInterval, into directory: URL) async -> String -} diff --git a/Trio/Sources/Logger/Contracts/KnownLogSubsystems.swift b/Trio/Sources/Logger/Contracts/KnownLogSubsystems.swift deleted file mode 100644 index e8a530523..000000000 --- a/Trio/Sources/Logger/Contracts/KnownLogSubsystems.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -enum KnownLogSubsystems { - static let all: [String] = [Bundle.main.bundleIdentifier].compactMap { $0 } + [ - "com.loopkit.OmnipodKit", - "com.randallknutson.OmniBLE", - "com.loopkit.MinimedKit", - "com.loopkit.CGMBLEKit", - "org.loopkit.G7SensorKit", - "org.loopkit.G7SensorKitUI", - "org.loopkit.G7SensorPlugin", - "com.ps2.rileylink", - "com.loopkit.LibreTransmitter", - "com.loopkit.libre", - "com.randallknutson.DanaKit", - "org.nightscout.MedtrumKit", - "org.nightscout.EversenseKit", - "com.bastiaanv.AccuChekKit", - "org.loopkit.LibreLoop", - "com.loopkit.ShareClient", - "org.tidepool.TidepoolService", - "com.loopkit.LoopKit" - ] -} diff --git a/Trio/Sources/Logger/Core/TrioLogger.swift b/Trio/Sources/Logger/Core/TrioLogger.swift index 85330cc1e..266146cb6 100644 --- a/Trio/Sources/Logger/Core/TrioLogger.swift +++ b/Trio/Sources/Logger/Core/TrioLogger.swift @@ -65,6 +65,19 @@ extension Logger.Category { } } +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 } diff --git a/Trio/Sources/Logger/Export/DeviceLogExporter.swift b/Trio/Sources/Logger/Export/DeviceLogExporter.swift deleted file mode 100644 index 2d46c7a1e..000000000 --- a/Trio/Sources/Logger/Export/DeviceLogExporter.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Foundation -import LoopKit -import Swinject - -final class DeviceLogExporter: DeviceLogExporting, Injectable { - @Injected() private var deviceLogStore: DeviceLogStore! - @Injected() private var deviceDataManager: DeviceDataManager! - @Injected() private var fetchGlucoseManager: FetchGlucoseManager! - - static let kitFolders = ["danakit", "medtrumkit", "eversense", "accuchek", "libreloop"] - private static let sliceDuration: TimeInterval = 6 * 60 * 60 - - private struct ManagerSnapshot { - let identifier: String - let title: String - let description: String - } - - private let documentsDirectory: URL - private var managerProvider: (() -> [DeviceManager])? - - private let dateFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - - init(resolver: Resolver) { - documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - injectServices(resolver) - } - - init(store: DeviceLogStore, documentsDirectory: URL, managers: @escaping () -> [DeviceManager]) { - self.documentsDirectory = documentsDirectory - managerProvider = managers - deviceLogStore = store - } - - func export(_ request: DeviceLogExportRequest, into directory: URL) async throws { - try await exportDeviceLogs(request, into: directory) - if request.includeOSLog { - let since = max(request.window.start, OSLogCapture.processStartDate ?? request.window.start) - OSLogCapture.write(subsystems: KnownLogSubsystems.all, since: since, into: directory) - } - if request.includeKitFiles { - copyKitFiles(into: directory) - } - } - - @MainActor private func liveManagers() -> [ManagerSnapshot] { - let managers: [DeviceManager] - if let managerProvider { - managers = managerProvider() - } else { - let candidates: [DeviceManager?] = [deviceDataManager?.pumpManager, fetchGlucoseManager?.cgmManager] - managers = candidates.compactMap { $0 } - } - return managers.map { - ManagerSnapshot(identifier: $0.pluginIdentifier, title: $0.localizedTitle, description: String(reflecting: $0)) - } - } - - private func exportDeviceLogs(_ request: DeviceLogExportRequest, into directory: URL) async throws { - let snapshots = await liveManagers().filter { - request.managerIdentifier == nil || $0.identifier == request.managerIdentifier - } - var handles: [String: FileHandle] = [:] - defer { handles.values.forEach { try? $0.close() } } - - func handle(for identifier: String) throws -> FileHandle { - if let handle = handles[identifier] { return handle } - let header = snapshots.first { $0.identifier == identifier } - .map { "# \($0.title) (\(identifier))\n\n```\n\($0.description)\n```\n\n" } - ?? "# \(identifier)\n\n" - let url = directory.appendingPathComponent("DeviceLog-\(identifier).txt") - guard FileManager.default.createFile(atPath: url.path, contents: Data(header.utf8)) else { - throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: url.path]) - } - let handle = try FileHandle(forWritingTo: url) - try handle.seekToEnd() - handles[identifier] = handle - return handle - } - - for snapshot in snapshots { - _ = try handle(for: snapshot.identifier) - } - if let identifier = request.managerIdentifier { - _ = try handle(for: identifier) - } - - guard let deviceLogStore else { return } - var sliceStart = request.window.start - while sliceStart < request.window.end { - let sliceEnd = min(sliceStart.addingTimeInterval(Self.sliceDuration), request.window.end) - let entries = await deviceLogStore.entries( - in: DateInterval(start: sliceStart, end: sliceEnd), - managerIdentifier: request.managerIdentifier - ) - for entry in entries { - try handle(for: entry.managerIdentifier).write(contentsOf: Data(line(for: entry).utf8)) - } - sliceStart = sliceEnd - } - } - - private func line(for entry: StoredDeviceLogEntry) -> String { - let timestamp = dateFormatter.string(from: entry.timestamp) - let device = entry.deviceIdentifier ?? "-" - return "* \(timestamp) \(entry.managerIdentifier) \(device) \(entry.type.rawValue) \(entry.message)\n" - } - - private func copyKitFiles(into directory: URL) { - let fileManager = FileManager.default - for folder in Self.kitFolders { - let source = documentsDirectory.appendingPathComponent(folder, isDirectory: true) - guard let files = try? fileManager.contentsOfDirectory( - at: source, - includingPropertiesForKeys: [.isRegularFileKey] - ) else { continue } - for file in files where (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true { - do { - try fileManager.copyItem( - at: file, - to: directory.appendingPathComponent("Kit-\(folder)-\(file.lastPathComponent)") - ) - } catch { - warning(.deviceManager, "Could not copy kit log \(file.lastPathComponent): \(error)") - } - } - } - } -} diff --git a/Trio/Sources/Logger/Export/LogExport.swift b/Trio/Sources/Logger/Export/LogExport.swift new file mode 100644 index 000000000..a7bb11618 --- /dev/null +++ b/Trio/Sources/Logger/Export/LogExport.swift @@ -0,0 +1,288 @@ +import CoreData +import Foundation +import LoopKit +import OSLog +import Swinject +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! + + // 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 includesAlgorithm: Bool + + init(resolver: Resolver) { + appLog = { TrioLogger.sink } + managers = nil + traceSubsystems = OSLogCapture.subsystems + includesAlgorithm = true + injectServices(resolver) + } + + init(appLog: any LogSink, deviceLogStore: DeviceLogStore, pump: DeviceManager?, cgm: DeviceManager?) { + self.appLog = { appLog } + managers = { (pump, cgm) } + traceSubsystems = [:] + includesAlgorithm = false + 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) + do { + let file = try await writeLog(kind, days: days, into: directory.appendingPathComponent("log", isDirectory: true)) + let archive = try Self.zip(file, in: directory) + 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 + await appLog?.flush() + let traceStore = traceLabels.isEmpty ? nil : try? OSLogStore(scope: .currentProcessIdentifier) + let redactor = Redactor(exactValues: await exactValues(in: window)) + let algorithm = kind == .all && includesAlgorithm + + 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 algorithm { + lines += await Self.algorithmLines(in: slice) + } + return lines + } + return url + } + + @MainActor private func deviceSnapshots() -> (LogFileHeader.Device?, LogFileHeader.Device?) { + let pump: DeviceManager? + let cgm: DeviceManager? + if let managers { + (pump, cgm) = managers() + } else { + pump = deviceDataManager?.pumpManager + cgm = fetchGlucoseManager?.cgmGlucoseSourceType == .plugin ? fetchGlucoseManager?.cgmManager : nil + } + 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 = "" + } + 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)") + } + + private static func algorithmLines(in window: DateInterval) async -> [LogLine] { + let context = CoreDataStack.shared.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(.service, "Could not read determinations for the log file: \(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 exactValues(in window: DateInterval) async -> [Redactor.ExactValue] { + var values: [Redactor.ExactValue] = [] + let url: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) + if let url, !url.isEmpty { + values.append(Redactor.ExactValue(value: url, kind: .url)) + if url.hasSuffix("/") { + values.append(Redactor.ExactValue(value: String(url.dropLast()), kind: .url)) + } + if let host = URL(string: url)?.host { + values.append(Redactor.ExactValue(value: host, kind: .host)) + } + } + let secret: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) + if let secret, !secret.isEmpty { + values.append(Redactor.ExactValue(value: secret, kind: .secret)) + } + var identifiers = Set() + if let deviceLogStore { + for slice in LogFileWriter.slices(of: window) { + for entry in await deviceLogStore.entries(in: slice) { + if let identifier = entry.deviceIdentifier, !identifier.isEmpty { + identifiers.insert(identifier) + } + } + } + } + values += identifiers.sorted().map { Redactor.ExactValue(value: $0, kind: .device) } + 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 000000000..aa2c0a859 --- /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 000000000..0005e8488 --- /dev/null +++ b/Trio/Sources/Logger/Export/LogFileWriter.swift @@ -0,0 +1,77 @@ +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) { + 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/LogShareAction.swift b/Trio/Sources/Logger/Export/LogShareAction.swift deleted file mode 100644 index 934490444..000000000 --- a/Trio/Sources/Logger/Export/LogShareAction.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -import UIKit - -enum LogShareAction { - @MainActor private 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 - } - } - - static func archive(named name: String, contents: @escaping (URL) async throws -> Void) async throws -> URL { - let backgroundTask = await BackgroundTask(name: name) - let fileManager = FileManager.default - let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - // The zip's top-level folder takes the content directory's name - let directory = root.appendingPathComponent(name, isDirectory: true) - - do { - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - try await contents(directory) - let archive = try DiagnosticArchiver.zip(directory, name: name) - try? fileManager.removeItem(at: root) - await backgroundTask.end() - return archive - } catch { - try? fileManager.removeItem(at: root) - await backgroundTask.end() - throw error - } - } - - static func discard(_ url: URL) { - try? FileManager.default.removeItem(at: url) - } -} diff --git a/Trio/Sources/Logger/Export/OSLogCapture.swift b/Trio/Sources/Logger/Export/OSLogCapture.swift index 5bd595ff4..4e3ddf8cf 100644 --- a/Trio/Sources/Logger/Export/OSLogCapture.swift +++ b/Trio/Sources/Logger/Export/OSLogCapture.swift @@ -12,51 +12,43 @@ enum OSLogCapture { return Date(timeIntervalSince1970: TimeInterval(start.tv_sec) + TimeInterval(start.tv_usec) / 1_000_000) }() - static func write(subsystems: [String], since: Date, into directory: URL) { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - var handles: [String: FileHandle] = [:] - var written: [URL] = [] + // 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"], + "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 store = try OSLogStore(scope: .currentProcessIdentifier) let entries = try store.getEntries( - at: store.position(date: since), - matching: NSPredicate(format: "subsystem IN %@", subsystems) + 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 { - let handle: FileHandle - if let existing = handles[entry.subsystem] { - handle = existing - } else { - let url = directory.appendingPathComponent("OSLog-\(entry.subsystem).txt") - guard FileManager.default.createFile(atPath: url.path, contents: Data("# Current app session only\n".utf8)) - else { throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: url.path]) } - written.append(url) - handle = try FileHandle(forWritingTo: url) - try handle.seekToEnd() - handles[entry.subsystem] = handle - } - let line = - "\(formatter.string(from: entry.date)) \(label(for: entry.level)) [\(entry.category)] \(entry.composedMessage)\n" - try handle.write(contentsOf: Data(line.utf8)) + 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)" + )) } - handles.values.forEach { try? $0.close() } + return lines } catch { - handles.values.forEach { try? $0.close() } - written.forEach { try? FileManager.default.removeItem(at: $0) } - warning(.service, "Could not capture os_log entries: \(error)") - } - } - - private static func label(for level: OSLogEntryLog.Level) -> String { - switch level { - case .debug: return "debug" - case .info: return "info" - case .notice: return "notice" - case .error: return "error" - case .fault: return "fault" - default: return "default" + warning(.service, "Could not read Bluetooth traces: \(error)") + return [] } } } diff --git a/Trio/Sources/Logger/Report/RedactionPatterns.swift b/Trio/Sources/Logger/Export/RedactionPatterns.swift similarity index 81% rename from Trio/Sources/Logger/Report/RedactionPatterns.swift rename to Trio/Sources/Logger/Export/RedactionPatterns.swift index ef68b7e1a..a038ee8df 100644 --- a/Trio/Sources/Logger/Report/RedactionPatterns.swift +++ b/Trio/Sources/Logger/Export/RedactionPatterns.swift @@ -11,9 +11,9 @@ enum RedactionPatterns { static let all: [Pattern] = [ make(#"https?://[^\s"'<>)]+"#, kind: .url), make(#"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"#, kind: .email), - make(#"Bearer\s+([A-Za-z0-9._-]+)"#, kind: .secret, group: 1), - make(#"(?i)\b(?:lot|tid)[:\s]+(\d+)\b"#, kind: .pumpSerial, group: 1), - make(#"(?i)\b(?:transmitter(?:ID)?|TID)[:\s"=]+([A-Za-z0-9]{6})\b"#, kind: .cgmSerial, group: 1), + make(#"Bearer[ \t]+([A-Za-z0-9._-]+)"#, kind: .secret, group: 1), + make(#"(?i)\b(?:lot|tid)[: \t]+(\d+)\b"#, kind: .pumpSerial, group: 1), + make(#"(?i)\b(?:transmitter(?:ID)?|TID)[: \t"=]+([A-Za-z0-9]{6})\b"#, kind: .cgmSerial, group: 1), make(labelled: ["lotNo", "lotSeq", "pumpSN"], kind: .pumpSerial), make(labelled: ["sensorID", "sensorSerial", "transmitterSerial", "bleNameString"], kind: .cgmSerial), make(labelled: ["deviceName", "serialNumber"], kind: .serial), @@ -30,7 +30,7 @@ enum RedactionPatterns { // Kit debug descriptions print serials as `label: value` or `label: Optional("value")` private static func make(labelled labels: [String], kind: Redactor.Kind) -> Pattern? { make( - #"(?i)\b(?:"# + labels.joined(separator: "|") + #")"?\s*[:=]\s*(?:Optional\()?"?([^\s"),<][^\s"),]{2,})"#, + #"(?i)\b(?:"# + labels.joined(separator: "|") + #")"?[ \t]*[:=][ \t]*(?:Optional\()?"?([^\s"),<][^\s"),]{2,})"#, kind: kind, group: 1 ) diff --git a/Trio/Sources/Logger/Report/Redactor.swift b/Trio/Sources/Logger/Export/Redactor.swift similarity index 53% rename from Trio/Sources/Logger/Report/Redactor.swift rename to Trio/Sources/Logger/Export/Redactor.swift index 28f79f3e3..2af88ecdd 100644 --- a/Trio/Sources/Logger/Report/Redactor.swift +++ b/Trio/Sources/Logger/Export/Redactor.swift @@ -17,9 +17,7 @@ final class Redactor { let kind: Kind } - static let textExtensions: Set = ["md", "txt", "json", "log", "csv"] private static let minimumExactLength = 4 - private static let chunkSize = 1 << 20 private let exactValues: [ExactValue] private let patterns: [RedactionPatterns.Pattern] @@ -50,59 +48,6 @@ final class Redactor { return result } - // Rewrites text files in place and removes anything that cannot be redacted - func redactFiles(in directory: URL) throws { - let fileManager = FileManager.default - guard let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey]) - else { return } - // Collect first so the temporary rewrite files are never enumerated - let urls = enumerator.allObjects.compactMap { $0 as? URL } - for url in urls { - guard (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { continue } - guard Self.textExtensions.contains(url.pathExtension.lowercased()) else { - try fileManager.removeItem(at: url) - continue - } - do { - try redactFile(at: url) - } catch { - warning(.service, "Could not redact \(url.lastPathComponent), leaving it out: \(error)") - if fileManager.fileExists(atPath: url.path) { - try fileManager.removeItem(at: url) - } - } - } - } - - func redactFile(at url: URL) throws { - let fileManager = FileManager.default - let data = try Data(contentsOf: url, options: .mappedIfSafe) - let output = url.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).redacting") - guard fileManager.createFile(atPath: output.path, contents: nil) else { - throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: output.path]) - } - do { - let handle = try FileHandle(forWritingTo: output) - defer { try? handle.close() } - var start = data.startIndex - while start < data.endIndex { - // Patterns never span lines, so chunks end on a newline - var end = min(start + Self.chunkSize, data.endIndex) - if end < data.endIndex { - end = data[end...].firstIndex(of: 0x0A).map { $0 + 1 } ?? data.endIndex - } - let chunk = String(decoding: data[start ..< end], as: UTF8.self) - try handle.write(contentsOf: Data(redact(chunk).utf8)) - start = end - } - try fileManager.removeItem(at: url) - try fileManager.moveItem(at: output, to: url) - } catch { - try? fileManager.removeItem(at: output) - throw error - } - } - private func apply(_ pattern: RedactionPatterns.Pattern, to text: String) -> String { let source = text as NSString let matches = pattern.regex.matches(in: text, range: NSRange(location: 0, length: source.length)) diff --git a/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift b/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift deleted file mode 100644 index 8612f3561..000000000 --- a/Trio/Sources/Logger/IssueReporter/SimpleLogReporter.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation - -enum SimpleLogReporter { - static var logFile: String { - getDocumentsDirectory().appendingPathComponent("logs/log.txt").path - } - - static var logDir: String { - getDocumentsDirectory().appendingPathComponent("logs").path - } - - static var logFilePrev: String { - getDocumentsDirectory().appendingPathComponent("logs/log_prev.txt").path - } - - static func getDocumentsDirectory() -> URL { - let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - let documentsDirectory = paths[0] - return documentsDirectory - } -} - -extension SimpleLogReporter { - static var watchLogFile: String { - getDocumentsDirectory().appendingPathComponent("logs/watch_log.txt").path - } - - static var watchLogFilePrev: String { - getDocumentsDirectory().appendingPathComponent("logs/watch_log_prev.txt").path - } -} diff --git a/Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift b/Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift deleted file mode 100644 index cdf487f96..000000000 --- a/Trio/Sources/Logger/Report/DiagnosticReportGenerator.swift +++ /dev/null @@ -1,269 +0,0 @@ -import Foundation -import os -import Swinject - -final class DiagnosticReportGenerator: Injectable { - @Injected() private var deviceLogStore: DeviceLogStore! - @Injected() private var deviceLogExporter: DeviceLogExporting! - @Injected() private var deviceDataManager: DeviceDataManager! - @Injected() private var fetchGlucoseManager: FetchGlucoseManager! - @Injected() private var settingsManager: SettingsManager! - @Injected() private var keychain: Keychain! - @Injected() private var reachabilityManager: ReachabilityManager! - - private struct RenderedSection { - let title: String - let order: Int - let body: String - let directory: URL? - } - - private static let deviceLogSlice: TimeInterval = 6 * 60 * 60 - - private let sectionTimeout: TimeInterval - private let sectionsOverride: ((Bool) -> [DiagnosticReportSection])? - private let exactValuesOverride: ((DateInterval) async -> [Redactor.ExactValue])? - - init(resolver: Resolver) { - sectionTimeout = 10 - sectionsOverride = nil - exactValuesOverride = nil - injectServices(resolver) - } - - init( - sectionTimeout: TimeInterval, - sections: @escaping (Bool) -> [DiagnosticReportSection], - exactValues: @escaping (DateInterval) async -> [Redactor.ExactValue] = { _ in [] } - ) { - self.sectionTimeout = sectionTimeout - sectionsOverride = sections - exactValuesOverride = exactValues - } - - @discardableResult func generate(window: DateInterval, includeDebug: Bool, into directory: URL) async throws -> String { - let fileManager = FileManager.default - // Sections write into staging so a timed-out section can never add unredacted files to the report - let staging = fileManager.temporaryDirectory.appendingPathComponent("report-\(UUID().uuidString)", isDirectory: true) - try fileManager.createDirectory(at: staging, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: staging) } - - let sections = sectionsOverride?(includeDebug) ?? defaultSections(includeDebug: includeDebug) - let timeout = sectionTimeout - let rendered = await withTaskGroup(of: RenderedSection.self) { group in - for (index, section) in sections.enumerated() { - let sectionDirectory = staging.appendingPathComponent("\(index)", isDirectory: true) - group.addTask { - try? FileManager.default.createDirectory(at: sectionDirectory, withIntermediateDirectories: true) - let body = await Self.render(section, window: window, into: sectionDirectory, timeout: timeout) - return RenderedSection( - title: section.title, - order: section.order, - body: body ?? "_timed out_", - directory: body == nil ? nil : sectionDirectory - ) - } - } - var results: [RenderedSection] = [] - for await result in group { - results.append(result) - } - return results.sorted { $0.order < $1.order } - } - - for section in rendered { - if let sectionDirectory = section.directory { - moveContents(of: sectionDirectory, to: directory, prefix: "\(section.order)-") - } - } - - let markdown = Self.markdown(for: rendered, window: window, generatedAt: Date()) - let reportURL = directory.appendingPathComponent("Trio Report \(ReportFormatting.fileStamp(Date())).md") - try markdown.write(to: reportURL, atomically: true, encoding: .utf8) - - let exactValues: [Redactor.ExactValue] - if let exactValuesOverride { - exactValues = await exactValuesOverride(window) - } else { - exactValues = await defaultExactValues(in: window) - } - try Redactor(exactValues: exactValues).redactFiles(in: directory) - return try String(contentsOf: reportURL, encoding: .utf8) - } - - func archive(window: DateInterval, includeDebug: Bool) async throws -> URL { - try await LogShareAction.archive(named: "Trio-Report-\(ReportFormatting.archiveStamp(Date()))") { directory in - _ = try await self.generate(window: window, includeDebug: includeDebug, into: directory) - } - } - - func markdown(window: DateInterval, includeDebug: Bool) async throws -> String { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - return try await generate(window: window, includeDebug: includeDebug, into: directory) - } - - private static func render( - _ section: DiagnosticReportSection, - window: DateInterval, - into directory: URL, - timeout: TimeInterval - ) async -> String? { - let finished = OSAllocatedUnfairLock(initialState: false) - let claim = { finished.withLock { done in - defer { done = true } - return !done - } } - return await withCheckedContinuation { continuation in - let work = Task { - let body = await section.render(window: window, into: directory) - if claim() { continuation.resume(returning: body) } - } - Task { - try? await Task.sleep(nanoseconds: UInt64(max(timeout, 0) * 1_000_000_000)) - if claim() { - work.cancel() - continuation.resume(returning: nil) - } - } - } - } - - private static func markdown(for sections: [RenderedSection], window: DateInterval, generatedAt: Date) -> String { - var lines = [ - "# Trio Report", - "", - "Generated at: \(ReportFormatting.timestamp(generatedAt))", - "Window: \(ReportFormatting.timestamp(window.start)) – \(ReportFormatting.timestamp(window.end))" - ] - for section in sections { - lines += ["", "## \(section.title)", "", section.body.trimmingCharacters(in: .newlines)] - } - return lines.joined(separator: "\n") + "\n" - } - - private func moveContents(of source: URL, to destination: URL, prefix: String) { - let fileManager = FileManager.default - guard let files = try? fileManager.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) else { return } - for file in files { - var target = destination.appendingPathComponent(file.lastPathComponent) - if fileManager.fileExists(atPath: target.path) { - target = destination.appendingPathComponent(prefix + file.lastPathComponent) - } - do { - try fileManager.moveItem(at: file, to: target) - } catch { - warning(.service, "Could not add \(file.lastPathComponent) to the report: \(error)") - } - } - } - - private func defaultSections(includeDebug: Bool) -> [DiagnosticReportSection] { - let deviceDataManager = self.deviceDataManager - let fetchGlucoseManager = self.fetchGlucoseManager - return [ - BuildSection(), - DeviceInfoSection(), - SettingsSection(settingsManager: settingsManager), - DeviceManagersSection( - pumpManager: { deviceDataManager?.pumpManager }, - cgmManager: { fetchGlucoseManager?.cgmManager } - ), - AlertsSection(), - DeterminationsSection(), - LoopTimingSection(), - NightscoutSection(settingsManager: settingsManager, keychain: keychain, reachabilityManager: reachabilityManager), - WatchSection(), - AppLogSection(includeDebug: includeDebug), - DeviceLogSection(exporter: deviceLogExporter) - ] - } - - private func defaultExactValues(in window: DateInterval) async -> [Redactor.ExactValue] { - var values: [Redactor.ExactValue] = [] - let url: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) - if let url, !url.isEmpty { - values.append(Redactor.ExactValue(value: url, kind: .url)) - if url.hasSuffix("/") { - values.append(Redactor.ExactValue(value: String(url.dropLast()), kind: .url)) - } - if let host = URL(string: url)?.host { - values.append(Redactor.ExactValue(value: host, kind: .host)) - } - } - let secret: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) - if let secret, !secret.isEmpty { - values.append(Redactor.ExactValue(value: secret, kind: .secret)) - } - for identifier in await deviceIdentifiers(in: window).sorted() { - values.append(Redactor.ExactValue(value: identifier, kind: .device)) - } - return values - } - - private func deviceIdentifiers(in window: DateInterval) async -> Set { - guard let deviceLogStore else { return [] } - var identifiers = Set() - var sliceStart = window.start - while sliceStart < window.end { - let sliceEnd = min(sliceStart.addingTimeInterval(Self.deviceLogSlice), window.end) - for entry in await deviceLogStore.entries(in: DateInterval(start: sliceStart, end: sliceEnd)) { - if let identifier = entry.deviceIdentifier, !identifier.isEmpty { - identifiers.insert(identifier) - } - } - sliceStart = sliceEnd - } - return identifiers - } -} - -enum ReportFormatting { - private static let timestampFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - - 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 - }() - - static func timestamp(_ date: Date?) -> String { - date.map { timestampFormatter.string(from: $0) } ?? "-" - } - - static func number(_ value: Decimal?) -> String { - value.flatMap { numberFormatter.string(from: $0 as NSDecimalNumber) } ?? "-" - } - - static func number(_ value: Double) -> String { - numberFormatter.string(from: NSNumber(value: value)) ?? "\(value)" - } - - static func fenced(_ text: String) -> String { - let fence = text.contains("```") ? "~~~~" : "```" - return "\(fence)\n\(text.trimmingCharacters(in: .newlines))\n\(fence)" - } - - static func fileStamp(_ date: Date) -> String { - stamp(date, format: "yyyy-MM-dd HH-mm") - } - - static func archiveStamp(_ date: Date) -> String { - stamp(date, format: "yyyyMMdd-HHmm") - } - - private static func stamp(_ date: Date, format: String) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = format - return formatter.string(from: date) - } -} diff --git a/Trio/Sources/Logger/Report/DiagnosticReportView.swift b/Trio/Sources/Logger/Report/DiagnosticReportView.swift deleted file mode 100644 index a17fdfdb4..000000000 --- a/Trio/Sources/Logger/Report/DiagnosticReportView.swift +++ /dev/null @@ -1,130 +0,0 @@ -import SwiftUI -import UIKit - -struct DiagnosticReportView: View { - let generator: DiagnosticReportGenerator? - - @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) private var colorScheme - @Environment(AppState.self) private var appState - - @State private var days = 3 - @State private var includeDebug = true - @State private var isGenerating = false - @State private var archive: URL? - @State private var showShareSheet = false - @State private var showCopied = false - @State private var showError = false - - private static let dayOptions = [1, 3, 7] - - var body: some View { - NavigationStack { - List { - Section( - content: { - Picker("Time window", selection: $days) { - ForEach(Self.dayOptions, id: \.self) { day in - day == 1 ? Text("1 day") : Text("\(day) days") - } - } - Toggle("Include debug lines", isOn: $includeDebug) - }, - footer: { - Text("Identifiers such as URLs, secrets, and device serials are replaced before sharing.") - } - ) - .listRowBackground(Color.chart) - - Section( - content: { - Button(action: share) { - HStack { - Text("Share Report") - Spacer() - if isGenerating { - ProgressView() - } - } - } - Button(action: copyMarkdown) { - HStack { - Text("Copy Markdown") - Spacer() - if showCopied { - Label("Copied", systemImage: "checkmark.circle.fill") - .font(.footnote) - .foregroundColor(.secondary) - } - } - } - }, - footer: { - if showError { - Text("Could not prepare logs").foregroundColor(.red) - } - } - ) - .disabled(isGenerating || generator == nil) - .listRowBackground(Color.chart) - } - .scrollContentBackground(.hidden) - .background(appState.trioBackgroundColor(for: colorScheme)) - .navigationTitle("Diagnostic Report") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Close") { dismiss() } - } - } - .sheet(isPresented: $showShareSheet, onDismiss: discardArchive) { - ShareSheet(activityItems: archive.map { [$0] } ?? []) - } - .onDisappear(perform: discardArchive) - } - } - - private var window: DateInterval { - let now = Date() - return DateInterval(start: now.addingTimeInterval(-Double(days) * 24 * 60 * 60), end: now) - } - - private func share() { - run { generator in - archive = try await generator.archive(window: window, includeDebug: includeDebug) - showShareSheet = true - } - } - - private func copyMarkdown() { - run { generator in - UIPasteboard.general.string = try await generator.markdown(window: window, includeDebug: includeDebug) - UINotificationFeedbackGenerator().notificationOccurred(.success) - withAnimation { showCopied = true } - try? await Task.sleep(nanoseconds: 1_500_000_000) - withAnimation { showCopied = false } - } - } - - private func run(_ action: @escaping @MainActor(DiagnosticReportGenerator) async throws -> Void) { - guard let generator else { return } - isGenerating = true - showError = false - Task { @MainActor in - do { - try await action(generator) - } catch { - warning(.service, "Could not prepare diagnostic report: \(error)") - showError = true - } - isGenerating = false - } - } - - private func discardArchive() { - if let archive { - LogShareAction.discard(archive) - } - archive = nil - } -} diff --git a/Trio/Sources/Logger/Report/Sections/AlertsSection.swift b/Trio/Sources/Logger/Report/Sections/AlertsSection.swift deleted file mode 100644 index 0de920162..000000000 --- a/Trio/Sources/Logger/Report/Sections/AlertsSection.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation - -struct AlertsSection: DiagnosticReportSection { - let title = "Alerts" - let order = 40 - var defaults: UserDefaults = .standard - - // Read-only view of BaseAlertHistoryStorage's persisted history - static let historyKey = "openaps.monitor.alertHistory.data" - private static let limit = 200 - - func render(window: DateInterval, into _: URL) async -> String { - guard let data = defaults.data(forKey: Self.historyKey), - let alerts = try? JSONCoding.decoder.decode([AlertEntry].self, from: data) - else { return "_No alert history_" } - let inWindow = alerts.filter { window.contains($0.issuedDate) }.sorted { $0.issuedDate > $1.issuedDate } - guard !inWindow.isEmpty else { return "_No alerts in window (Trio keeps 24 hours of alert history)_" } - var lines = ["Trio keeps 24 hours of alert history. Newest first."] - lines.append("") - lines += inWindow.prefix(Self.limit).map(Self.line) - if inWindow.count > Self.limit { - lines.append("- … \(inWindow.count - Self.limit) older alerts omitted") - } - return lines.joined(separator: "\n") - } - - private static func line(_ alert: AlertEntry) -> String { - var parts = [ - ReportFormatting.timestamp(alert.issuedDate), - "\(alert.managerIdentifier)/\(alert.alertIdentifier)", - "trigger \(alert.triggerType)" - ] - 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 \(ReportFormatting.timestamp($0))" } ?? "unacknowledged") - if let error = alert.errorMessage { - parts.append("error: \(error)") - } - return "- " + parts.joined(separator: " · ") - } -} diff --git a/Trio/Sources/Logger/Report/Sections/AppLogSection.swift b/Trio/Sources/Logger/Report/Sections/AppLogSection.swift deleted file mode 100644 index 130cfa50b..000000000 --- a/Trio/Sources/Logger/Report/Sections/AppLogSection.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -struct AppLogSection: DiagnosticReportSection { - let title = "App log" - let order = 90 - let includeDebug: Bool - var sink: () -> any LogSink = { TrioLogger.sink } - var legacyDirectory: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent("logs", isDirectory: true) - - static let legacyFiles = ["log.txt", "log_prev.txt", "watch_log.txt"] - private static let markdownLimit = 200 - - func render(window: DateInterval, into directory: URL) async -> String { - let sink = self.sink() - await sink.flush() - let minimumLevel: LogLevel = includeDebug ? .debug : .info - let records = await sink.records(in: window, minimumLevel: minimumLevel).sorted { $0.timestamp < $1.timestamp } - - var notes = ["\(records.count) records at \(minimumLevel) and above in AppLog.txt."] - do { - try write(records, to: directory.appendingPathComponent("AppLog.txt")) - } catch { - notes.append("AppLog.txt could not be written: \(error.localizedDescription)") - } - let copied = copyLegacyFiles(into: directory) - if !copied.isEmpty { - notes.append("Older text logs: \(copied.joined(separator: ", ")).") - } - - let warnings = records.filter { $0.level >= .warning }.suffix(Self.markdownLimit).map(Self.line) - guard !warnings.isEmpty else { return (notes + ["", "_No warnings or errors in window_"]).joined(separator: "\n") } - let tail = [ - "", - "Last \(warnings.count) warnings and errors:", - "", - ReportFormatting.fenced(warnings.joined(separator: "\n")) - ] - return (notes + tail).joined(separator: "\n") - } - - static func line(_ record: LogRecord) -> String { - "\(ReportFormatting.timestamp(record.timestamp)) [\(String(describing: record.level).uppercased())] [\(record.category)] \(record.file):\(record.line) \(record.function) - \(record.message)" - } - - private func write(_ records: [LogRecord], to url: URL) 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() } - var start = 0 - while start < records.count { - let end = min(start + 1000, records.count) - let chunk = records[start ..< end].map { Self.line($0) + "\n" }.joined() - try handle.write(contentsOf: Data(chunk.utf8)) - start = end - } - } - - private func copyLegacyFiles(into directory: URL) -> [String] { - Self.legacyFiles.compactMap { name in - let source = legacyDirectory.appendingPathComponent(name) - guard FileManager.default.fileExists(atPath: source.path) else { return nil } - let target = "Legacy-\(name)" - do { - try FileManager.default.copyItem(at: source, to: directory.appendingPathComponent(target)) - return target - } catch { - warning(.service, "Could not copy \(name) into the report: \(error)") - return nil - } - } - } -} diff --git a/Trio/Sources/Logger/Report/Sections/BuildSection.swift b/Trio/Sources/Logger/Report/Sections/BuildSection.swift deleted file mode 100644 index c00ce315b..000000000 --- a/Trio/Sources/Logger/Report/Sections/BuildSection.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -struct BuildSection: DiagnosticReportSection { - let title = "Build" - let order = 0 - var buildDetails: BuildDetails = .shared - var bundle: Bundle = .main - - func render(window _: DateInterval, into _: URL) async -> String { - let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" - let build = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "unknown" - var lines = [ - "- Version: \(version) (\(build))", - "- Dev version: \(bundle.appDevVersion ?? "unknown")", - "- Branch: \(buildDetails.trioBranch)", - "- Commit: \(buildDetails.trioCommitSHA)", - "- Built: \(buildDetails.buildDateString ?? "unknown")", - "- Expires: \(ReportFormatting.timestamp(buildDetails.calculateExpirationDate()))", - "- TestFlight: \(buildDetails.isTestFlightBuild() ? "yes" : "no")" - ] - let submodules = buildDetails.submodules.sorted { $0.key < $1.key } - if !submodules.isEmpty { - lines.append("- Submodules:") - lines += submodules.map { " - \($0.key): \($0.value.branch) \($0.value.commitSHA)" } - } - return lines.joined(separator: "\n") - } -} diff --git a/Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift b/Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift deleted file mode 100644 index 6b6bba0ff..000000000 --- a/Trio/Sources/Logger/Report/Sections/DeterminationsSection.swift +++ /dev/null @@ -1,139 +0,0 @@ -import CoreData -import Foundation - -struct DeterminationSnapshot: Codable, Equatable { - var id: UUID? - var deliverAt: Date? - var timestamp: Date? - var timestampEnacted: Date? - var enacted: Bool - var received: Bool - var isUploadedToNS: Bool - var glucose: Decimal? - var iob: Decimal? - var cob: Int16 - var insulinSensitivity: Decimal? - var sensitivityRatio: Decimal? - var currentTarget: Decimal? - var eventualBG: Decimal? - var rate: Decimal? - var duration: Decimal? - var temp: String? - var smbToDeliver: Decimal? - var insulinReq: Decimal? - var carbRatio: Decimal? - var carbsRequired: Int16 - var scheduledBasal: Decimal? - var reservoir: Decimal? - var threshold: Decimal? - var expectedDelta: Decimal? - var minDelta: Decimal? - var reason: String? - var forecasts: [String: [Int32]] - - var markdown: String { - let fields = [ - "\(ReportFormatting.timestamp(deliverAt ?? timestamp)) \(enacted ? "enacted" : "suggested")", - "glucose \(ReportFormatting.number(glucose))", - "iob \(ReportFormatting.number(iob))", - "cob \(cob)", - "isf \(ReportFormatting.number(insulinSensitivity))", - "sensitivityRatio \(ReportFormatting.number(sensitivityRatio))", - "rate \(ReportFormatting.number(rate)) for \(ReportFormatting.number(duration)) min", - "smbToDeliver \(ReportFormatting.number(smbToDeliver))", - "eventualBG \(ReportFormatting.number(eventualBG))", - "target \(ReportFormatting.number(currentTarget))" - ] - let fence = ReportFormatting.fenced(reason ?? "").components(separatedBy: "\n").map { " \($0)" } - return (["- " + fields.joined(separator: " · ")] + fence).joined(separator: "\n") - } - - static func markdown(for snapshots: [DeterminationSnapshot], limit: Int = 100) -> String { - guard !snapshots.isEmpty else { return "_No determinations in window_" } - let newestFirst = snapshots.sorted { ($0.deliverAt ?? .distantPast) > ($1.deliverAt ?? .distantPast) } - var lines = ["\(snapshots.count) runs in window, newest first. All runs with forecasts in Determinations.json."] - if snapshots.count > limit { - lines.append("Showing the newest \(limit).") - } - lines.append("") - lines += newestFirst.prefix(limit).map(\.markdown) - return lines.joined(separator: "\n") - } -} - -extension DeterminationSnapshot { - init(_ determination: OrefDetermination) { - var forecasts: [String: [Int32]] = [:] - for forecast in determination.forecasts ?? [] { - let values = (forecast.forecastValues ?? []).sorted { $0.index < $1.index }.map(\.value) - forecasts[forecast.type ?? "unknown", default: []] += values - } - self.init( - id: determination.id, - deliverAt: determination.deliverAt, - timestamp: determination.timestamp, - timestampEnacted: determination.timestampEnacted, - enacted: determination.enacted, - received: determination.received, - isUploadedToNS: determination.isUploadedToNS, - glucose: determination.glucose?.decimalValue, - iob: determination.iob?.decimalValue, - cob: determination.cob, - insulinSensitivity: determination.insulinSensitivity?.decimalValue, - sensitivityRatio: determination.sensitivityRatio?.decimalValue, - currentTarget: determination.currentTarget?.decimalValue, - eventualBG: determination.eventualBG?.decimalValue, - rate: determination.rate?.decimalValue, - duration: determination.duration?.decimalValue, - temp: determination.temp, - smbToDeliver: determination.smbToDeliver?.decimalValue, - insulinReq: determination.insulinReq?.decimalValue, - carbRatio: determination.carbRatio?.decimalValue, - carbsRequired: determination.carbsRequired, - scheduledBasal: determination.scheduledBasal?.decimalValue, - reservoir: determination.reservoir?.decimalValue, - threshold: determination.threshold?.decimalValue, - expectedDelta: determination.expectedDelta?.decimalValue, - minDelta: determination.minDelta?.decimalValue, - reason: determination.reason, - forecasts: forecasts - ) - } -} - -struct DeterminationsSection: DiagnosticReportSection { - let title = "Determinations" - let order = 50 - var makeContext: () -> NSManagedObjectContext = { CoreDataStack.shared.newTaskContext() } - - func render(window: DateInterval, into directory: URL) async -> String { - let context = makeContext() - context.name = "diagnosticReportDeterminations" - let snapshots: [DeterminationSnapshot] - do { - snapshots = 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.sortDescriptors = [NSSortDescriptor(key: "deliverAt", ascending: false)] - request.relationshipKeyPathsForPrefetching = ["forecasts", "forecasts.forecastValues"] - request.fetchBatchSize = 100 - return try context.fetch(request).map(DeterminationSnapshot.init) - } - } catch { - return "_Determinations could not be fetched: \(error.localizedDescription)_" - } - - let encoder = JSONCoding.encoder - encoder.outputFormatting.insert(.sortedKeys) - do { - try encoder.encode(snapshots).write(to: directory.appendingPathComponent("Determinations.json")) - } catch { - warning(.service, "Could not write determinations for the report: \(error)") - } - return DeterminationSnapshot.markdown(for: snapshots) - } -} diff --git a/Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift b/Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift deleted file mode 100644 index 203f6e712..000000000 --- a/Trio/Sources/Logger/Report/Sections/DeviceInfoSection.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation - -struct DeviceInfoSection: DiagnosticReportSection { - let title = "Device" - let order = 10 - - func render(window _: DateInterval, into _: URL) async -> String { - let processInfo = ProcessInfo.processInfo - return [ - "- OS: \(processInfo.operatingSystemVersionString)", - "- Model: \(Self.modelIdentifier)", - "- Low Power Mode: \(processInfo.isLowPowerModeEnabled ? "on" : "off")", - "- Thermal state: \(Self.label(for: processInfo.thermalState))", - "- Free disk: \(Self.freeDisk)", - "- Time zone: \(TimeZone.current.identifier)" - ].joined(separator: "\n") - } - - 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 freeDisk: 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) - } - - private static func label(for state: ProcessInfo.ThermalState) -> String { - switch state { - case .nominal: return "nominal" - case .fair: return "fair" - case .serious: return "serious" - case .critical: return "critical" - @unknown default: return "unknown" - } - } -} diff --git a/Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift b/Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift deleted file mode 100644 index c0663a426..000000000 --- a/Trio/Sources/Logger/Report/Sections/DeviceLogSection.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -struct DeviceLogSection: DiagnosticReportSection { - let title = "Device log" - let order = 100 - let exporter: DeviceLogExporting? - - private static let tailLines = 100 - - func render(window: DateInterval, into directory: URL) async -> String { - guard let exporter else { return "_Device log unavailable_" } - var lines: [String] = [] - do { - try await exporter.export( - DeviceLogExportRequest(window: window, managerIdentifier: nil, includeOSLog: true, includeKitFiles: true), - into: directory - ) - } catch { - lines.append("Export stopped early: \(error.localizedDescription)") - } - - let files = ((try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []).sorted() - let deviceLogs = files.filter { $0.hasPrefix("DeviceLog-") && $0.hasSuffix(".txt") } - let others = files.filter { !deviceLogs.contains($0) } - if !others.isEmpty { - lines.append("Also attached: \(others.joined(separator: ", ")).") - } - guard !deviceLogs.isEmpty else { return (lines + ["", "_No pump or CGM device log_"]).joined(separator: "\n") } - - for name in deviceLogs { - let url = directory.appendingPathComponent(name) - let entries = ((try? String(contentsOf: url, encoding: .utf8)) ?? "") - .components(separatedBy: "\n") - .filter { $0.hasPrefix("* ") } - lines += ["", "### \(name)", ""] - if entries.isEmpty { - lines.append("_No entries in window_") - } else { - lines.append("Last \(min(entries.count, Self.tailLines)) of \(entries.count) entries:") - lines += ["", ReportFormatting.fenced(entries.suffix(Self.tailLines).joined(separator: "\n"))] - } - } - return lines.joined(separator: "\n") - } -} diff --git a/Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift b/Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift deleted file mode 100644 index bbe14f67c..000000000 --- a/Trio/Sources/Logger/Report/Sections/DeviceManagersSection.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -import LoopKit - -struct DeviceManagersSection: DiagnosticReportSection { - let title = "Pump and CGM" - let order = 30 - let pumpManager: @MainActor() -> DeviceManager? - let cgmManager: @MainActor() -> DeviceManager? - - private static let descriptionLineLimit = 90 - - private struct Snapshot { - let identifier: String - let title: String - let description: String - } - - func render(window _: DateInterval, into _: URL) async -> String { - let (pump, cgm) = await MainActor.run { - (pumpManager().map(Self.snapshot), cgmManager().map(Self.snapshot)) - } - return [Self.markdown(heading: "Pump", pump), Self.markdown(heading: "CGM", cgm)].joined(separator: "\n\n") - } - - private static func snapshot(_ manager: DeviceManager) -> Snapshot { - Snapshot(identifier: manager.pluginIdentifier, title: manager.localizedTitle, description: String(reflecting: manager)) - } - - private static func markdown(heading: String, _ snapshot: Snapshot?) -> String { - guard let snapshot else { return "### \(heading)\n\n_Not configured_" } - var description = snapshot.description.components(separatedBy: .newlines) - if description.count > descriptionLineLimit { - description = Array(description.prefix(descriptionLineLimit)) - description.append("… truncated, full state in DeviceLog-\(snapshot.identifier).txt") - } - return [ - "### \(heading)", - "", - "- Plugin: \(snapshot.identifier)", - "- Name: \(snapshot.title)", - "", - ReportFormatting.fenced(description.joined(separator: "\n")) - ].joined(separator: "\n") - } -} diff --git a/Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift b/Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift deleted file mode 100644 index b22efdead..000000000 --- a/Trio/Sources/Logger/Report/Sections/LoopTimingSection.swift +++ /dev/null @@ -1,60 +0,0 @@ -import CoreData -import Foundation - -struct LoopTimingSection: DiagnosticReportSection { - let title = "Loop timing" - let order = 60 - var makeContext: () -> NSManagedObjectContext = { CoreDataStack.shared.newTaskContext() } - - private static let limit = 50 - - private struct Run { - let start: Date? - let end: Date? - let seconds: Double - let status: String - } - - func render(window: DateInterval, into _: URL) async -> String { - let context = makeContext() - context.name = "diagnosticReportLoopTiming" - let runs: [Run] - do { - runs = try await context.perform { - let request: NSFetchRequest = LoopStatRecord.fetchRequest() - request.predicate = NSPredicate( - format: "start >= %@ AND start <= %@", - window.start as NSDate, - window.end as NSDate - ) - request.sortDescriptors = [NSSortDescriptor(key: "start", ascending: false)] - request.fetchLimit = Self.limit - return try context.fetch(request).map { record in - // Stored duration is in minutes - let seconds = record.start.flatMap { start in record.end.map { $0.timeIntervalSince(start) } } - ?? record.duration * 60 - return Run(start: record.start, end: record.end, seconds: seconds, status: record.loopStatus ?? "-") - } - } - } catch { - return "_Loop statistics could not be fetched: \(error.localizedDescription)_" - } - guard !runs.isEmpty else { return "_No loop runs in window_" } - - let durations = runs.map(\.seconds).sorted() - var lines = [ - "Last \(runs.count) runs, newest first. Median \(ReportFormatting.number(Self.median(durations))) s, max \(ReportFormatting.number(durations.last ?? 0)) s.", - "" - ] - lines += runs.map { - "- \(ReportFormatting.timestamp($0.start)) → \(ReportFormatting.timestamp($0.end)) · \(ReportFormatting.number($0.seconds)) s · \($0.status.replacingOccurrences(of: "\n", with: " "))" - } - return lines.joined(separator: "\n") - } - - static func median(_ sorted: [Double]) -> Double { - guard !sorted.isEmpty else { return 0 } - let middle = sorted.count / 2 - return sorted.count.isMultiple(of: 2) ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] - } -} diff --git a/Trio/Sources/Logger/Report/Sections/NightscoutSection.swift b/Trio/Sources/Logger/Report/Sections/NightscoutSection.swift deleted file mode 100644 index 299dc8137..000000000 --- a/Trio/Sources/Logger/Report/Sections/NightscoutSection.swift +++ /dev/null @@ -1,59 +0,0 @@ -import CoreData -import Foundation - -struct NightscoutSection: DiagnosticReportSection { - let title = "Nightscout" - let order = 70 - let settingsManager: SettingsManager? - let keychain: Keychain? - let reachabilityManager: ReachabilityManager? - var makeContext: () -> NSManagedObjectContext = { CoreDataStack.shared.newTaskContext() } - - func render(window: DateInterval, into _: URL) async -> String { - let url: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) - let secret: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) - let settings = settingsManager?.settings - var lines = [ - "- URL configured: \(Self.yesNo(url?.isEmpty == false))", - "- Host: \(url.flatMap { URL(string: $0)?.host } ?? "-")", - "- API secret configured: \(Self.yesNo(secret?.isEmpty == false))", - "- Upload enabled: \(Self.yesNo(settings?.isUploadEnabled))", - "- Download enabled: \(Self.yesNo(settings?.isDownloadEnabled))", - "- Upload glucose: \(Self.yesNo(settings?.uploadGlucose))", - "- Upload CGM sensor states: \(Self.yesNo(settings?.uploadCGMSensorStates))", - "- Nightscout as CGM: \(Self.yesNo(settings.map { $0.cgm == .nightscout }))", - "- Network reachable: \(Self.yesNo(reachabilityManager?.isReachable))" - ] - - let context = makeContext() - context.name = "diagnosticReportNightscout" - let start = window.start as NSDate - let end = window.end as NSDate - let summary: [String] = await context.perform { - func pending(_ entity: String, dateKey: String) -> String { - let request = NSFetchRequest(entityName: entity) - request.predicate = NSPredicate( - format: "%K >= %@ AND %K <= %@ AND isUploadedToNS == NO", - dateKey, start, dateKey, end - ) - return (try? context.count(for: request)).map(String.init) ?? "-" - } - let lastUploaded: NSFetchRequest = OrefDetermination.fetchRequest() - lastUploaded.predicate = NSPredicate(format: "isUploadedToNS == YES") - lastUploaded.sortDescriptors = [NSSortDescriptor(key: "deliverAt", ascending: false)] - lastUploaded.fetchLimit = 1 - let lastDate = (try? context.fetch(lastUploaded))?.first?.deliverAt - return [ - "- Newest uploaded determination: \(ReportFormatting.timestamp(lastDate))", - "- Not uploaded in window: determinations \(pending("OrefDetermination", dateKey: "deliverAt")), glucose \(pending("GlucoseStored", dateKey: "date")), carbs \(pending("CarbEntryStored", dateKey: "date")), pump events \(pending("PumpEventStored", dateKey: "timestamp"))" - ] - } - lines += summary - lines += ["", "Trio does not record individual upload or download results; see the nightscout lines in AppLog.txt."] - return lines.joined(separator: "\n") - } - - private static func yesNo(_ value: Bool?) -> String { - value.map { $0 ? "yes" : "no" } ?? "-" - } -} diff --git a/Trio/Sources/Logger/Report/Sections/SettingsSection.swift b/Trio/Sources/Logger/Report/Sections/SettingsSection.swift deleted file mode 100644 index ed506de31..000000000 --- a/Trio/Sources/Logger/Report/Sections/SettingsSection.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -struct SettingsSection: DiagnosticReportSection { - let title = "Settings" - let order = 20 - let settingsManager: SettingsManager? - - private static let groups = [ - ("settings", "Trio settings"), - ("preferences", "Algorithm preferences"), - ("pumpSettings", "Pump settings") - ] - - private struct Snapshot: Encodable { - let settings: TrioSettings - let preferences: Preferences - let pumpSettings: PumpSettings - } - - func render(window _: DateInterval, into directory: URL) async -> String { - guard let settingsManager else { return "_Settings unavailable_" } - let snapshot = Snapshot( - settings: settingsManager.settings, - preferences: settingsManager.preferences, - pumpSettings: settingsManager.pumpSettings - ) - let encoder = JSONCoding.encoder - encoder.outputFormatting.insert(.sortedKeys) - do { - let data = try encoder.encode(snapshot) - try data.write(to: directory.appendingPathComponent("Settings.json")) - guard let groups = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return "_Settings could not be listed_" - } - var lines = ["Full values in Settings.json."] - for (name, heading) in Self.groups { - guard let values = groups[name] as? [String: Any] else { continue } - lines += ["", "### \(heading)", ""] - lines += values.keys.sorted().map { "- \($0): \(Self.describe(values[$0]))" } - } - return lines.joined(separator: "\n") - } catch { - return "_Settings could not be encoded: \(error.localizedDescription)_" - } - } - - 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/Report/Sections/WatchSection.swift b/Trio/Sources/Logger/Report/Sections/WatchSection.swift deleted file mode 100644 index 79fdb8874..000000000 --- a/Trio/Sources/Logger/Report/Sections/WatchSection.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation -import WatchConnectivity - -struct WatchSection: DiagnosticReportSection { - let title = "Apple Watch" - let order = 80 - - func render(window _: DateInterval, into _: URL) async -> String { - guard WCSession.isSupported() else { return "_Watch connectivity not supported on this device_" } - let session = WCSession.default - var lines = ["- Session: \(Self.label(for: session.activationState))"] - // Pairing properties are only valid once the session is activated - if session.activationState == .activated { - lines += [ - "- Paired: \(session.isPaired ? "yes" : "no")", - "- Watch app installed: \(session.isWatchAppInstalled ? "yes" : "no")", - "- Reachable: \(session.isReachable ? "yes" : "no")", - "- Complication enabled: \(session.isComplicationEnabled ? "yes" : "no")", - "- Outstanding transfers: \(session.outstandingUserInfoTransfers.count)" - ] - } - return lines.joined(separator: "\n") - } - - private static func label(for state: WCSessionActivationState) -> String { - switch state { - case .activated: return "activated" - case .inactive: return "inactive" - case .notActivated: return "not activated" - @unknown default: return "unknown" - } - } -} diff --git a/Trio/Sources/Logger/Store/AppLogTextExporter.swift b/Trio/Sources/Logger/Store/AppLogTextExporter.swift deleted file mode 100644 index da9d9fdd4..000000000 --- a/Trio/Sources/Logger/Store/AppLogTextExporter.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation - -enum AppLogTextExporter { - static let fileName = "AppLog.txt" - static let legacyFileNames = ["log.txt", "log_prev.txt", "watch_log.txt"] - private static let sliceDuration: TimeInterval = 6 * 60 * 60 - - private static let timestampFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - - static func line(for record: LogRecord) -> String { - "\(timestampFormatter.string(from: record.timestamp)) [\(record.level.label)] [\(record.category)] \(record.file):\(record.line) \(record.function) - \(record.message)\n" - } - - static func write(records: [LogRecord], to url: URL) throws { - let handle = try createFile(at: url) - defer { try? handle.close() } - try append(records, to: handle) - } - - static func export( - from store: AppLogStore, - window: DateInterval, - minimumLevel: LogLevel = .debug, - into directory: URL - ) async throws { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let handle = try createFile(at: directory.appendingPathComponent(fileName)) - defer { try? handle.close() } - - var start = window.start - while true { - let end = min(start.addingTimeInterval(sliceDuration), window.end) - let isLast = end >= window.end - let records = await store.records(in: DateInterval(start: start, end: end), minimumLevel: minimumLevel) - // Slice ends are inclusive; leave boundary records to the next slice - try append(isLast ? records : records.filter { $0.timestamp < end }, to: handle) - if isLast { break } - start = end - } - - for name in legacyFileNames { - let source = store.directory.appendingPathComponent(name) - guard FileManager.default.fileExists(atPath: source.path) else { continue } - let destination = directory.appendingPathComponent("Legacy-\(name)") - try? FileManager.default.removeItem(at: destination) - try FileManager.default.copyItem(at: source, to: destination) - } - } - - private static func createFile(at url: URL) throws -> FileHandle { - guard FileManager.default.createFile(atPath: url.path, contents: nil) else { - throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: url.path]) - } - return try FileHandle(forWritingTo: url) - } - - private static func append(_ records: [LogRecord], to handle: FileHandle) throws { - var buffer = Data() - for record in records { - buffer.append(contentsOf: line(for: record).utf8) - if buffer.count >= 64 * 1024 { - try handle.write(contentsOf: buffer) - buffer.removeAll(keepingCapacity: true) - } - } - if !buffer.isEmpty { - try handle.write(contentsOf: buffer) - } - } -} - -extension LogLevel { - var label: String { - switch self { - case .debug: return "DEBUG" - case .info: return "INFO" - case .notice: return "NOTICE" - case .warning: return "WARNING" - case .error: return "ERROR" - case .fault: return "FAULT" - } - } - - 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: " - } - } -} diff --git a/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift b/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift index 7dadc1697..e1a57da75 100644 --- a/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift +++ b/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift @@ -44,7 +44,7 @@ extension CGMSettings { @Injected() var broadcaster: Broadcaster! @Injected() var nightscoutManager: NightscoutManager! @Injected() var bluetoothManager: BluetoothStateManager! - @Injected() var deviceLogExporter: DeviceLogExporting! + @Injected() var logExport: LogExport! @Published var units: GlucoseUnits = .mgdL @Published var shouldDisplayCGMSetupSheet: Bool = false diff --git a/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift b/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift index cfea32b36..2bb2b3c3d 100644 --- a/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift +++ b/Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift @@ -93,17 +93,15 @@ extension CGMSettings { } if state.cgmCurrent.type == .plugin, - let cgmManager = state.fetchGlucoseManager.cgmManager, - state.cgmCurrent.id == state.fetchGlucoseManager.cgmGlucosePluginId + state.fetchGlucoseManager?.cgmManager != nil, + state.cgmCurrent.id == state.fetchGlucoseManager?.cgmGlucosePluginId { Section { - DeviceLogShareRow( - title: String(localized: "Share CGM Logs"), - footnote: String( - localized: "Includes everything this device reported to Trio in the last 3 days. Low-level Bluetooth traces cover the current app session only." - ), - pluginIdentifier: cgmManager.pluginIdentifier, - exporter: state.deviceLogExporter + LogShareRow( + title: "Share CGM Logs", + footnote: "Creates one file you can send when asking for help.", + kinds: [.cgm], + logExport: state.logExport ) }.listRowBackground(Color.chart) } diff --git a/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift b/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift index d2ad4e94e..69881ea1e 100644 --- a/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift +++ b/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift @@ -11,7 +11,7 @@ extension PumpConfig { @Published var pumpState: PumpDisplayState? private(set) var initialSettings: PumpInitialSettings = .default @Injected() var bluetoothManager: BluetoothStateManager! - @Injected() var deviceLogExporter: DeviceLogExporting! + @Injected() var logExport: LogExport! override func subscribe() { provider.pumpDisplayState diff --git a/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift b/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift index b991f848b..2af235b6e 100644 --- a/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift +++ b/Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift @@ -81,15 +81,13 @@ extension PumpConfig { } ).listRowBackground(Color.chart) - if state.pumpState != nil, let pumpManager = state.provider.apsManager.pumpManager { + if state.pumpState != nil, state.provider?.apsManager?.pumpManager != nil { Section { - DeviceLogShareRow( - title: String(localized: "Share Pump Logs"), - footnote: String( - localized: "Includes everything this device reported to Trio in the last 3 days. Low-level Bluetooth traces cover the current app session only." - ), - pluginIdentifier: pumpManager.pluginIdentifier, - exporter: state.deviceLogExporter + LogShareRow( + title: "Share Pump Logs", + footnote: "Creates one file you can send when asking for help.", + kinds: [.pump], + logExport: state.logExport ) }.listRowBackground(Color.chart) } diff --git a/Trio/Sources/Modules/Settings/SettingsStateModel.swift b/Trio/Sources/Modules/Settings/SettingsStateModel.swift index 7286cd7d6..7d266b7d0 100644 --- a/Trio/Sources/Modules/Settings/SettingsStateModel.swift +++ b/Trio/Sources/Modules/Settings/SettingsStateModel.swift @@ -8,8 +8,7 @@ import TidepoolServiceKit extension Settings { final class StateModel: BaseStateModel { @Injected() private var broadcaster: Broadcaster! - @Injected() private var deviceLogExporter: DeviceLogExporting! - @Injected() var diagnosticReportGenerator: DiagnosticReportGenerator! + @Injected() var logExport: LogExport! @Injected() private var nightscoutManager: NightscoutManager! @Injected() var pluginManager: PluginManager! @Injected() var fetchCgmManager: FetchGlucoseManager! @@ -45,33 +44,16 @@ extension Settings { serviceUIType = TidepoolService.self as? ServiceUI.Type } - func deviceLogsArchive() async throws -> URL { - let now = Date() - let request = DeviceLogExportRequest( - window: DateInterval(start: now.addingTimeInterval(-3 * 24 * 60 * 60), end: now), - managerIdentifier: nil, - includeOSLog: true, - includeKitFiles: true - ) - let exporter = deviceLogExporter - return try await LogShareAction.archive(named: "Trio-DeviceLogs-\(Self.archiveStamp(now))") { directory in - try await exporter?.export(request, into: directory) + 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) } - } - - func appLogsArchive() async throws -> URL { - let now = Date() - let window = DateInterval(start: now.addingTimeInterval(-3 * 24 * 60 * 60), end: now) - return try await LogShareAction.archive(named: "Trio-AppLogs-\(Self.archiveStamp(now))") { directory in - try await AppLogTextExporter.export(from: .shared, window: window, into: directory) + if let fetchCgmManager, fetchCgmManager.cgmGlucoseSourceType == .plugin, fetchCgmManager.cgmManager != nil { + kinds.append(.cgm) } - } - - private static func archiveStamp(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyyMMdd-HHmm" - return formatter.string(from: date) + return kinds } func hideSettingsModal() { diff --git a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift index 3a46a56b9..fe4cd914f 100644 --- a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift +++ b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift @@ -18,11 +18,7 @@ extension Settings { let resolver: Resolver @StateObject var state = StateModel() - @State private var showShareSheet = false - @State private var logArchive: URL? - @State private var isPreparingLogs = false @State private var showLogErrorToast = false - @State private var showDiagnosticReport = false @State private var searchText: String = "" @State var hintDetent = PresentationDetent.large @State private var hintPayload: HintPayload? @@ -133,20 +129,11 @@ extension Settings { } } - private func shareLogs(_ makeArchive: @escaping () async throws -> URL) { - isPreparingLogs = true + private func showLogError() { Task { @MainActor in - do { - logArchive = try await makeArchive() - isPreparingLogs = false - showShareSheet = true - } catch { - isPreparingLogs = false - warning(.service, "Could not prepare logs for sharing: \(error)") - withAnimation { showLogErrorToast = true } - try? await Task.sleep(nanoseconds: 2_000_000_000) - withAnimation { showLogErrorToast = false } - } + withAnimation { showLogErrorToast = true } + try? await Task.sleep(nanoseconds: 2_000_000_000) + withAnimation { showLogErrorToast = false } } } @@ -304,26 +291,12 @@ extension Settings { Section( header: Text("Support & Community"), content: { - Menu { - Button("Device logs") { shareLogs(state.deviceLogsArchive) } - Button("App logs") { shareLogs(state.appLogsArchive) } - Button("Diagnostic report") { showDiagnosticReport = true } - } label: { - HStack { - Text("Share Logs") - .foregroundColor(.primary) - Spacer() - if isPreparingLogs { - ProgressView() - } else { - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.footnote) - } - } - } - .disabled(isPreparingLogs) - .frame(maxWidth: .infinity, alignment: .leading) + LogShareRow( + title: "Share Logs", + kinds: state.logKinds, + logExport: state.logExport, + onFailure: showLogError + ) Button { if let url = URL(string: "https://github.com/nightscout/Trio/issues/new/choose") { @@ -489,17 +462,6 @@ extension Settings { sheetTitle: String(localized: "Help", comment: "Help sheet title") ) } - .sheet(isPresented: $showShareSheet, onDismiss: { - if let logArchive { - LogShareAction.discard(logArchive) - } - logArchive = nil - }) { - ShareSheet(activityItems: logArchive.map { [$0] } ?? []) - } - .sheet(isPresented: $showDiagnosticReport) { - DiagnosticReportView(generator: state.diagnosticReportGenerator) - } .onAppear(perform: configureView) .task { await releaseNotesService.load() diff --git a/Trio/Sources/Views/DeviceLogShareRow.swift b/Trio/Sources/Views/DeviceLogShareRow.swift deleted file mode 100644 index 9e404e1dd..000000000 --- a/Trio/Sources/Views/DeviceLogShareRow.swift +++ /dev/null @@ -1,103 +0,0 @@ -import SwiftUI - -struct DeviceLogShareRow: View { - let title: String - let footnote: String - let pluginIdentifier: String - let exporter: DeviceLogExporting - - @State private var isExporting = false - @State private var archiveURL: URL? - @State private var showShareSheet = false - @State private var showExportError = false - @State private var exportErrorMessage = "" - - static let window: TimeInterval = 3 * 24 * 60 * 60 - - static func request(for pluginIdentifier: String, endingAt date: Date) -> DeviceLogExportRequest { - DeviceLogExportRequest( - window: DateInterval(start: date.addingTimeInterval(-window), end: date), - managerIdentifier: pluginIdentifier, - includeOSLog: true, - includeKitFiles: true - ) - } - - private static func archiveName(for pluginIdentifier: String, at date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyyMMdd-HHmm" - return "Trio-\(pluginIdentifier)-\(formatter.string(from: date))" - } - - var body: some View { - VStack(alignment: .leading) { - Button { - share() - } label: { - HStack { - Text(title) - .foregroundColor(.primary) - Spacer() - if isExporting { - ProgressView() - } else { - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.footnote) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .disabled(isExporting) - - Text(footnote) - .font(.footnote) - .foregroundColor(.secondary) - .lineLimit(nil) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 2) - } - .sheet(isPresented: $showShareSheet, onDismiss: removeArchive) { - if let archiveURL { - ShareSheet(activityItems: [archiveURL]) - } - } - .alert("Export Error", isPresented: $showExportError) { - Button("OK", role: .cancel) {} - } message: { - Text(exportErrorMessage) - } - } - - private func share() { - guard !isExporting else { return } - isExporting = true - let now = Date() - let request = Self.request(for: pluginIdentifier, endingAt: now) - let name = Self.archiveName(for: pluginIdentifier, at: now) - let exporter = self.exporter - - Task { @MainActor in - defer { isExporting = false } - do { - archiveURL = try await LogShareAction.archive(named: name) { directory in - try await exporter.export(request, into: directory) - } - showShareSheet = true - } catch { - exportErrorMessage = error.localizedDescription - showExportError = true - } - } - } - - private func removeArchive() { - guard let archiveURL else { return } - self.archiveURL = nil - Task.detached(priority: .utility) { - try? FileManager.default.removeItem(at: archiveURL) - } - } -} diff --git a/Trio/Sources/Views/LogShareRow.swift b/Trio/Sources/Views/LogShareRow.swift new file mode 100644 index 000000000..7aca1c086 --- /dev/null +++ b/Trio/Sources/Views/LogShareRow.swift @@ -0,0 +1,128 @@ +import SwiftUI + +struct LogShareRow: View { + let title: LocalizedStringKey + var footnote: LocalizedStringKey? + let kinds: [LogExport.Kind] + let logExport: LogExport? + var onFailure: (() -> Void)? + + @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 showShareSheet = false + @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) + } + } + .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 } + } + ) + .onChange(of: showKinds) { _, shown in + // The sheet runs the chosen action before it clears its binding + if !shown, 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 } + ) + .sheet(isPresented: $showShareSheet, onDismiss: discardFile) { + ShareSheet(activityItems: file.map { [$0] } ?? []) + } + .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 { + file = try await logExport.makeFile(kind, days: days) + isPreparing = false + showShareSheet = true + } catch { + isPreparing = false + warning(.service, "Could not prepare logs for sharing: \(error)") + if let onFailure { + onFailure() + } else { + showError = true + } + } + } + } + + private func discardFile() { + guard let file else { return } + self.file = nil + Task.detached(priority: .utility) { + LogExport.discard(file) + } + } +} + +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/Logging/AppLogStoreTests.swift b/TrioTests/Logging/AppLogStoreTests.swift index 4f2b4e4e1..b75f2c269 100644 --- a/TrioTests/Logging/AppLogStoreTests.swift +++ b/TrioTests/Logging/AppLogStoreTests.swift @@ -156,28 +156,4 @@ import UIKit #expect(await Self.waitUntil { loader.didLoad }) #expect(await store.records(in: Self.everything, minimumLevel: .debug) == records) } - - @Test("Exports records as text lines") func exportsText() 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(-7 * 60 * 60), level: .warning)) - store.accept(Self.record(1, at: now)) - await store.flush() - try Data("legacy\n".utf8).write(to: store.directory.appendingPathComponent("log.txt")) - - let output = directory.appendingPathComponent("export", isDirectory: true) - try await AppLogTextExporter.export( - from: store, - window: DateInterval(start: now.addingTimeInterval(-24 * 60 * 60), end: now), - into: output - ) - - let lines = try String(contentsOf: output.appendingPathComponent(AppLogTextExporter.fileName), encoding: .utf8) - .split(separator: "\n") - #expect(lines.count == 2) - #expect(lines.first?.hasSuffix("[WARNING] [Service] File.swift:0 run() - message 0") == true) - #expect(lines.last?.hasSuffix("[INFO] [Service] File.swift:1 run() - message 1") == true) - #expect(FileManager.default.fileExists(atPath: output.appendingPathComponent("Legacy-log.txt").path)) - } } diff --git a/TrioTests/Logging/DeterminationsSectionTests.swift b/TrioTests/Logging/DeterminationsSectionTests.swift deleted file mode 100644 index d0cc203de..000000000 --- a/TrioTests/Logging/DeterminationsSectionTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import Testing - -@testable import Trio - -@Suite("Determinations Section Tests") struct DeterminationsSectionTests { - private static func snapshot(at date: Date, enacted: Bool, glucose: Decimal, reason: String) -> DeterminationSnapshot { - DeterminationSnapshot( - id: UUID(), - deliverAt: date, - timestamp: date, - timestampEnacted: enacted ? date : nil, - enacted: enacted, - received: enacted, - isUploadedToNS: false, - glucose: glucose, - iob: Decimal(string: "1.25"), - cob: 12, - insulinSensitivity: 45, - sensitivityRatio: Decimal(string: "0.9"), - currentTarget: 100, - eventualBG: 140, - rate: Decimal(string: "0.8"), - duration: 30, - temp: "absolute", - smbToDeliver: Decimal(string: "0.3"), - insulinReq: Decimal(string: "0.6"), - carbRatio: 10, - carbsRequired: 0, - scheduledBasal: 1, - reservoir: 100, - threshold: 70, - expectedDelta: 2, - minDelta: 1, - reason: reason, - forecasts: ["IOB": [123, 125, 127]] - ) - } - - @Test("Runs render newest first with a fenced reason") func rendersBullets() throws { - let older = Self.snapshot( - at: Date(timeIntervalSince1970: 1_790_000_000), - enacted: false, - glucose: 118, - reason: "COB: 12, Dev: 5; Eventual BG 140 >= 100" - ) - let newer = Self.snapshot( - at: Date(timeIntervalSince1970: 1_790_000_300), - enacted: true, - glucose: 123, - reason: "minPredBG 110; temp 0.8 >= 0.75" - ) - - let markdown = DeterminationSnapshot.markdown(for: [older, newer]) - let bullets = markdown.components(separatedBy: "\n").filter { $0.hasPrefix("- ") } - #expect(bullets.count == 2) - #expect(bullets[0].contains("enacted · glucose 123 · iob 1.25 · cob 12")) - #expect(bullets[0].contains("rate 0.8 for 30 min · smbToDeliver 0.3 · eventualBG 140 · target 100")) - #expect(bullets[1].contains("suggested · glucose 118")) - #expect(markdown.contains(" ```\n minPredBG 110; temp 0.8 >= 0.75\n ```")) - #expect(markdown.hasPrefix("2 runs in window")) - } - - @Test("Markdown is capped and the cap is noted") func capsRuns() { - let runs = (0 ..< 5).map { - Self.snapshot( - at: Date(timeIntervalSince1970: 1_790_000_000 + Double($0) * 300), - enacted: true, - glucose: 100, - reason: "r" - ) - } - let markdown = DeterminationSnapshot.markdown(for: runs, limit: 3) - #expect(markdown.components(separatedBy: "\n").filter { $0.hasPrefix("- ") }.count == 3) - #expect(markdown.contains("Showing the newest 3.")) - } - - @Test("Snapshots round-trip through JSON with forecasts") func encodesSnapshot() throws { - let value = Self.snapshot(at: Date(timeIntervalSince1970: 1_790_000_000), enacted: true, glucose: 123, reason: "r") - let data = try JSONCoding.encoder.encode([value]) - let decoded = try JSONCoding.decoder.decode([DeterminationSnapshot].self, from: data) - #expect(decoded.first?.forecasts == ["IOB": [123, 125, 127]]) - #expect(decoded.first?.reason == "r") - #expect(decoded.first?.deliverAt == value.deliverAt) - } -} diff --git a/TrioTests/Logging/DeviceLogExporterTests.swift b/TrioTests/Logging/DeviceLogExporterTests.swift deleted file mode 100644 index 2d9fa1210..000000000 --- a/TrioTests/Logging/DeviceLogExporterTests.swift +++ /dev/null @@ -1,182 +0,0 @@ -import Foundation -import LoopKit -import Testing - -@testable import Trio - -@Suite("Device Log Exporter Tests", .serialized) struct DeviceLogExporterTests { - private class StubManager: DeviceManager { - class var pluginIdentifier: String { "com.example.pump" } - var localizedTitle: String { "Example Pump" } - var debugDescription: String { "## StubManager\nstate: synthetic" } - 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 struct Fixture { - let directory: URL - let output: URL - let store: DeviceLogStore - let exporter: DeviceLogExporter - } - - private static func makeFixture(managers: [DeviceManager] = [StubManager(), StubCGMManager()]) throws -> Fixture { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - let output = directory.appendingPathComponent("export", isDirectory: true) - try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) - let store = DeviceLogStore( - storageURL: directory.appendingPathComponent("logs/DeviceLog.sqlite"), - isProtectedDataAvailable: { true } - ) - let exporter = DeviceLogExporter( - store: store, - documentsDirectory: directory.appendingPathComponent("Documents", isDirectory: true), - managers: { managers } - ) - return Fixture(directory: directory, output: output, store: store, exporter: exporter) - } - - 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 seed(_ store: DeviceLogStore) async { - await log(store, manager: "com.example.pump", device: "device-1", message: "pump one") - await log(store, manager: "com.example.cgm", device: nil, message: "cgm one") - await log(store, manager: "com.example.pump", device: "device-1", message: "pump two") - await log(store, manager: "com.example.orphan", device: "device-2", message: "orphan one") - } - - private static var recentWindow: DateInterval { - DateInterval(start: Date().addingTimeInterval(-3 * 24 * 60 * 60), end: Date().addingTimeInterval(60)) - } - - private static func request( - window: DateInterval = recentWindow, - managerIdentifier: String? = nil, - includeKitFiles: Bool = false - ) -> DeviceLogExportRequest { - DeviceLogExportRequest( - window: window, - managerIdentifier: managerIdentifier, - includeOSLog: false, - includeKitFiles: includeKitFiles - ) - } - - private static func files(in directory: URL) throws -> [String] { - try FileManager.default.contentsOfDirectory(atPath: directory.path).sorted() - } - - private static func entryLines(_ url: URL) throws -> [String] { - try String(contentsOf: url, encoding: .utf8) - .split(separator: "\n") - .map(String.init) - .filter { $0.hasPrefix("* ") } - } - - @Test("Exporting all writes one file per identifier") func exportsAll() async throws { - let fixture = try Self.makeFixture() - defer { try? FileManager.default.removeItem(at: fixture.directory) } - await Self.seed(fixture.store) - - try await fixture.exporter.export(Self.request(), into: fixture.output) - - #expect(try Self.files(in: fixture.output) == [ - "DeviceLog-com.example.cgm.txt", - "DeviceLog-com.example.orphan.txt", - "DeviceLog-com.example.pump.txt" - ]) - - let pumpURL = fixture.output.appendingPathComponent("DeviceLog-com.example.pump.txt") - let pump = try String(contentsOf: pumpURL, encoding: .utf8) - #expect(pump.hasPrefix("# Example Pump (com.example.pump)\n\n```\n## StubManager\nstate: synthetic\n```\n")) - - let pumpLines = try Self.entryLines(pumpURL) - #expect(pumpLines.count == 2) - #expect(pumpLines.allSatisfy { - $0.range( - of: #"^\* \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z com\.example\.pump device-1 send pump (one|two)$"#, - options: .regularExpression - ) != nil - }) - #expect(pumpLines.first?.hasSuffix("pump one") == true) - - let cgmLines = try Self.entryLines(fixture.output.appendingPathComponent("DeviceLog-com.example.cgm.txt")) - #expect(cgmLines.count == 1) - #expect(cgmLines.first?.hasSuffix(" com.example.cgm - send cgm one") == true) - - let orphanURL = fixture.output.appendingPathComponent("DeviceLog-com.example.orphan.txt") - #expect(try String(contentsOf: orphanURL, encoding: .utf8).hasPrefix("# com.example.orphan\n")) - #expect(try Self.entryLines(orphanURL).count == 1) - } - - @Test("Exporting one identifier writes only its file") func exportsOne() async throws { - let fixture = try Self.makeFixture() - defer { try? FileManager.default.removeItem(at: fixture.directory) } - await Self.seed(fixture.store) - - try await fixture.exporter.export(Self.request(managerIdentifier: "com.example.cgm"), into: fixture.output) - - #expect(try Self.files(in: fixture.output) == ["DeviceLog-com.example.cgm.txt"]) - #expect(try Self.entryLines(fixture.output.appendingPathComponent("DeviceLog-com.example.cgm.txt")).count == 1) - } - - @Test("Stored entries export without a live manager") func exportsWithoutLiveManager() async throws { - let fixture = try Self.makeFixture(managers: []) - defer { try? FileManager.default.removeItem(at: fixture.directory) } - await Self.seed(fixture.store) - - try await fixture.exporter.export(Self.request(managerIdentifier: "com.example.pump"), into: fixture.output) - - let url = fixture.output.appendingPathComponent("DeviceLog-com.example.pump.txt") - #expect(try Self.files(in: fixture.output) == ["DeviceLog-com.example.pump.txt"]) - #expect(try String(contentsOf: url, encoding: .utf8).hasPrefix("# com.example.pump\n")) - #expect(try Self.entryLines(url).count == 2) - } - - @Test("Entries outside the window are skipped") func filtersWindow() async throws { - let fixture = try Self.makeFixture() - defer { try? FileManager.default.removeItem(at: fixture.directory) } - await Self.seed(fixture.store) - - let past = DateInterval(start: Date().addingTimeInterval(-2 * 24 * 60 * 60), end: Date().addingTimeInterval(-60 * 60)) - try await fixture.exporter.export(Self.request(window: past), into: fixture.output) - - #expect(try Self.files(in: fixture.output) == ["DeviceLog-com.example.cgm.txt", "DeviceLog-com.example.pump.txt"]) - #expect(try Self.entryLines(fixture.output.appendingPathComponent("DeviceLog-com.example.pump.txt")).isEmpty) - #expect(try Self.entryLines(fixture.output.appendingPathComponent("DeviceLog-com.example.cgm.txt")).isEmpty) - } - - @Test("Kit files are copied with a prefix") func copiesKitFiles() async throws { - let fixture = try Self.makeFixture(managers: []) - defer { try? FileManager.default.removeItem(at: fixture.directory) } - let kitDirectory = fixture.directory.appendingPathComponent("Documents/danakit", isDirectory: true) - try FileManager.default.createDirectory(at: kitDirectory, withIntermediateDirectories: true) - try Data("synthetic".utf8).write(to: kitDirectory.appendingPathComponent("log.txt")) - - try await fixture.exporter.export(Self.request(includeKitFiles: true), into: fixture.output) - - #expect(try Self.files(in: fixture.output) == ["Kit-danakit-log.txt"]) - } -} diff --git a/TrioTests/Logging/DeviceLogShareRequestTests.swift b/TrioTests/Logging/DeviceLogShareRequestTests.swift deleted file mode 100644 index e11a0207b..000000000 --- a/TrioTests/Logging/DeviceLogShareRequestTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation -import Testing - -@testable import Trio - -@Suite("Device Log Share Request Tests") struct DeviceLogShareRequestTests { - @Test("Request targets the manager over the last 3 days with OSLog and kit files") func requestForManager() { - let now = Date(timeIntervalSince1970: 1_700_000_000) - - let request = DeviceLogShareRow.request(for: "TestPumpManager", endingAt: now) - - #expect(request.managerIdentifier == "TestPumpManager") - #expect(request.window.end == now) - #expect(request.window.duration == 3 * 24 * 60 * 60) - #expect(request.includeOSLog) - #expect(request.includeKitFiles) - } -} diff --git a/TrioTests/Logging/DiagnosticArchiverTests.swift b/TrioTests/Logging/DiagnosticArchiverTests.swift deleted file mode 100644 index bc5df62f5..000000000 --- a/TrioTests/Logging/DiagnosticArchiverTests.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation -import Testing - -@testable import Trio - -@Suite("Diagnostic Archiver Tests") struct DiagnosticArchiverTests { - @Test("Zips a directory into a valid archive") func testZipDirectory() throws { - let fileManager = FileManager.default - let directory = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: directory) } - - try Data("first".utf8).write(to: directory.appendingPathComponent("first.txt")) - try Data("second".utf8).write(to: directory.appendingPathComponent("second.txt")) - - let archive = try DiagnosticArchiver.zip(directory, name: "archiver-test-\(UUID().uuidString)") - defer { try? fileManager.removeItem(at: archive) } - - #expect(fileManager.fileExists(atPath: archive.path)) - #expect(archive.pathExtension == "zip") - let data = try Data(contentsOf: archive) - #expect(data.count > 22) - #expect(Array(data.prefix(4)) == [0x50, 0x4B, 0x03, 0x04]) - } -} diff --git a/TrioTests/Logging/DiagnosticReportGeneratorTests.swift b/TrioTests/Logging/DiagnosticReportGeneratorTests.swift deleted file mode 100644 index c44d0265f..000000000 --- a/TrioTests/Logging/DiagnosticReportGeneratorTests.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -import Testing - -@testable import Trio - -@Suite("Diagnostic Report Generator Tests") struct DiagnosticReportGeneratorTests { - private struct FakeSection: DiagnosticReportSection { - let title: String - let order: Int - var delay: TimeInterval = 0 - var sideFile: String? - - func render(window _: DateInterval, into directory: URL) async -> String { - if delay > 0 { - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } - if let sideFile { - try? Data("contact admin@example.com\n".utf8).write(to: directory.appendingPathComponent(sideFile)) - } - return "body of \(title)" - } - } - - 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 let window = DateInterval(start: Date(timeIntervalSince1970: 1_790_000_000), duration: 3 * 24 * 60 * 60) - - @Test("Sections are ordered and a slow section times out") func orderingAndTimeout() async throws { - let directory = try Self.makeDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let generator = DiagnosticReportGenerator(sectionTimeout: 0.3, sections: { _ in - [ - FakeSection(title: "Third", order: 30), - FakeSection(title: "Slow", order: 20, delay: 5, sideFile: "Slow.txt"), - FakeSection(title: "First", order: 10, sideFile: "First.txt") - ] - }) - - let started = Date() - let markdown = try await generator.generate(window: Self.window, includeDebug: true, into: directory) - #expect(Date().timeIntervalSince(started) < 3) - - let headings = markdown.components(separatedBy: "\n").filter { $0.hasPrefix("## ") } - #expect(headings == ["## First", "## Slow", "## Third"]) - #expect(markdown.contains("## Slow\n\n_timed out_")) - #expect(markdown.contains("body of First")) - #expect(markdown.hasPrefix("# Trio Report")) - #expect(markdown.contains("Window: ")) - - let files = try FileManager.default.contentsOfDirectory(atPath: directory.path) - #expect(files.contains("First.txt")) - #expect(!files.contains("Slow.txt")) - #expect(files.contains { $0.hasPrefix("Trio Report ") && $0.hasSuffix(".md") }) - } - - @Test("Side files and Markdown are redacted") func redactsBundle() async throws { - let directory = try Self.makeDirectory() - defer { try? FileManager.default.removeItem(at: directory) } - let generator = DiagnosticReportGenerator( - sectionTimeout: 2, - sections: { _ in [FakeSection(title: "Device ABC-1234", order: 0, sideFile: "Side.txt")] }, - exactValues: { _ in [Redactor.ExactValue(value: "ABC-1234", kind: .device)] } - ) - - let markdown = try await generator.generate(window: Self.window, includeDebug: false, into: directory) - #expect(markdown.contains("## Device ")) - #expect(!markdown.contains("ABC-1234")) - - let side = try String(contentsOf: directory.appendingPathComponent("Side.txt"), encoding: .utf8) - #expect(side == "contact \n") - } -} diff --git a/TrioTests/Logging/LogExportTests.swift b/TrioTests/Logging/LogExportTests.swift new file mode 100644 index 000000000..2fef6cde4 --- /dev/null +++ b/TrioTests/Logging/LogExportTests.swift @@ -0,0 +1,209 @@ +import Foundation +import LoopKit +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)) + return Fixture( + directory: directory, + export: LogExport(appLog: appLog, deviceLogStore: store, pump: StubManager(), cgm: StubCGMManager()) + ) + } + + 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("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 ")) + } +} diff --git a/TrioTests/Logging/RedactorTests.swift b/TrioTests/Logging/RedactorTests.swift index d03a5a506..131cdd077 100644 --- a/TrioTests/Logging/RedactorTests.swift +++ b/TrioTests/Logging/RedactorTests.swift @@ -37,7 +37,10 @@ import Testing "DanaKit", "com.randallknutson.DanaKit", "* 2026-10-18T12:00:00.000Z DanaKit - send rate 0.85 U/h for 30 min", - "Transmitter battery low" + "Transmitter battery low", + "Pod Lot:\n123456789 bolus", + "transmitterID:\nABC123 next line", + "deviceName:\nPump settings" ] ) func negatives(input: String) { #expect(Redactor(exactValues: []).redact(input) == input) @@ -64,23 +67,4 @@ import Testing ) #expect(output == "GET api host secret device abc") } - - @Test("Files are redacted in place and unknown file types are dropped") func redactsFiles() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let text = directory.appendingPathComponent("Log.txt") - let binary = directory.appendingPathComponent("Store.sqlite") - let lines = (0 ..< 20000).map { "line \($0) to https://example.test/x BG 123\n" }.joined() - try Data(lines.utf8).write(to: text) - try Data([0, 1, 2]).write(to: binary) - - try Redactor(exactValues: []).redactFiles(in: directory) - - let redacted = try String(contentsOf: text, encoding: .utf8) - #expect(!redacted.contains("example.test")) - #expect(redacted.components(separatedBy: "\n").count == 20001) - #expect(redacted.hasSuffix("line 19999 to BG 123\n")) - #expect(!FileManager.default.fileExists(atPath: binary.path)) - } } From d2eb49da010b0fefc21d5671569cf433a2f801dc Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:00:40 +0200 Subject: [PATCH 12/48] Add log categories by subject --- .../OpenAPSSwift/AlgorithmLoggingShim.swift | 21 ++++++++++++++++--- Trio/Sources/Application/TrioApp.swift | 14 ++++++------- Trio/Sources/Logger/Logger.swift | 21 ++++++++++++++++--- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift b/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift index 603dfa17c..79bea54bf 100644 --- a/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift +++ b/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift @@ -15,19 +15,34 @@ import Foundation enum Logger { /// Mirrors `Logger.Category` in the app target. enum Category: String { + case loop + case algorithm + case pump + case cgm + case bolus + case treatments + case nightscout + case remote + case healthKit + case alerts + case watch + case liveActivity + case storage + case settings + case telemetry + case ui + case lifecycle + // Legacy, removed once all call sites are migrated case `default` case service case businessLogic case openAPS case deviceManager case apsManager - case nightscout case remoteControl case bolusState case watchManager case coreData - case storage - case telemetry } } diff --git a/Trio/Sources/Application/TrioApp.swift b/Trio/Sources/Application/TrioApp.swift index 9e69aedc8..dcffb6c9b 100644 --- a/Trio/Sources/Application/TrioApp.swift +++ b/Trio/Sources/Application/TrioApp.swift @@ -157,10 +157,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 @@ -175,9 +171,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() diff --git a/Trio/Sources/Logger/Logger.swift b/Trio/Sources/Logger/Logger.swift index cace6cada..8d0313ae4 100644 --- a/Trio/Sources/Logger/Logger.swift +++ b/Trio/Sources/Logger/Logger.swift @@ -62,19 +62,34 @@ func fault( enum Logger { enum Category: String, CaseIterable { + case loop + case algorithm + case pump + case cgm + case bolus + case treatments + case nightscout + case remote + case healthKit + case alerts + case watch + case liveActivity + case storage + case settings + case telemetry + case ui + case lifecycle + // Legacy, removed once all call sites are migrated case `default` case service case businessLogic case openAPS case deviceManager case apsManager - case nightscout case remoteControl case bolusState case watchManager case coreData - case storage - case telemetry var name: String { rawValue.capitalizingFirstLetter() From 2bac3321ce288b31e25cecba9aa0b215d078269d Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:54:01 +0200 Subject: [PATCH 13/48] Clean up watch and Live Activity log messages --- .../Services/Calendar/CalendarManager.swift | 20 ++- .../ContactImage/ContactImageManager.swift | 39 +++--- .../LiveActivity/LiveActivityManager.swift | 51 ++++---- .../WatchManager/AppleWatchManager.swift | 119 ++++++++---------- .../Services/WatchManager/GarminManager.swift | 93 +++++++------- .../RestartLiveActivityIntent.swift | 2 +- .../RestartLiveActivityIntentRequest.swift | 4 +- .../OverridePresetsIntentRequest.swift | 27 ++-- .../Shortcuts/State/StateIntentRequest.swift | 3 +- .../TempPresetsIntentRequest.swift | 26 ++-- 10 files changed, 169 insertions(+), 215 deletions(-) diff --git a/Trio/Sources/Services/Calendar/CalendarManager.swift b/Trio/Sources/Services/Calendar/CalendarManager.swift index 972258ca6..c616e0434 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 d33305e9b..91d1959a1 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/LiveActivity/LiveActivityManager.swift b/Trio/Sources/Services/LiveActivity/LiveActivityManager.swift index 72d9f25ed..e0ec99115 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/WatchManager/AppleWatchManager.swift b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift index 1efbe8342..0dc446375 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 { @@ -654,7 +645,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 } @@ -666,31 +657,28 @@ 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] == nil, message[WatchMessageKeys.activateOverride] == nil, @@ -698,7 +686,10 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana message[WatchMessageKeys.activateTempTarget] == nil, message[WatchMessageKeys.requestBolusRecommendation] == nil { - debug(.watchManager, "📱 Invalid or incomplete data received from watch. Received: \(message)") + warning( + .watch, + "Invalid or incomplete message from watch, keys: \(message.keys.sorted().joined(separator: ", "))" + ) // Acknowledge failure self.sendAcknowledgment( toWatch: false, @@ -708,22 +699,22 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana } if message[WatchMessageKeys.cancelOverride] as? Bool == true { - debug(.watchManager, "📱 Received cancel override request from watch") + info(.watch, "Watch requested override cancel") self.handleCancelOverride() } if let presetName = message[WatchMessageKeys.activateOverride] as? String { - debug(.watchManager, "📱 Received activate override request from watch for preset: \(presetName)") + info(.watch, "Watch requested override preset \(presetName)") self.handleActivateOverride(presetName) } if let presetName = message[WatchMessageKeys.activateTempTarget] as? String { - debug(.watchManager, "📱 Received activate temp target request from watch for preset: \(presetName)") + info(.watch, "Watch requested temp target preset \(presetName)") self.handleActivateTempTarget(presetName) } if message[WatchMessageKeys.cancelTempTarget] as? Bool == true { - debug(.watchManager, "📱 Received cancel temp target request from watch") + info(.watch, "Watch requested temp target cancel") self.handleCancelTempTarget() } @@ -752,9 +743,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 @@ -774,7 +765,7 @@ 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) } } @@ -789,7 +780,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana } 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)) @@ -805,7 +796,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 @@ -833,7 +824,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") } } @@ -867,7 +858,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( @@ -878,8 +869,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) @@ -930,7 +921,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..." @@ -953,7 +944,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, @@ -964,8 +955,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) } } @@ -975,7 +966,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, @@ -986,7 +977,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.", @@ -1000,9 +991,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( @@ -1014,7 +1005,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)\".", @@ -1028,9 +1019,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( @@ -1042,7 +1033,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)\".", @@ -1056,7 +1047,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, @@ -1067,7 +1058,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.", @@ -1120,7 +1111,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/GarminManager.swift b/Trio/Sources/Services/WatchManager/GarminManager.swift index 353b8bb6f..c811f8239 100644 --- a/Trio/Sources/Services/WatchManager/GarminManager.swift +++ b/Trio/Sources/Services/WatchManager/GarminManager.swift @@ -109,7 +109,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+) @@ -328,8 +328,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" ) } @@ -337,7 +337,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) } } } @@ -346,7 +346,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") } } @@ -366,17 +366,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) } } } @@ -385,7 +382,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") } } @@ -401,7 +398,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable { pendingGlucoseFallback?.cancel() pendingGlucoseFallback = nil if debugWatchState { - debug(.watchManager, "Garmin: Determination arrived - cancelled glucose fallback timer") + debug(.watch, "Garmin fallback cancelled, determination arrived") } } } @@ -412,7 +409,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) } } } @@ -426,7 +423,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 } @@ -436,7 +433,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 { @@ -445,7 +442,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) } } @@ -458,7 +455,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") } } @@ -554,7 +551,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) @@ -709,7 +706,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 } @@ -721,8 +718,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)" ) } @@ -772,18 +769,18 @@ 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) connectIQ?.register(forAppMessages: watchfaceApp, delegate: self) } else if !isWatchfaceDataEnabled { - debugGarmin("Garmin: Watchface data disabled - skipping watchface registration") + debugGarmin("Garmin watchface data disabled, watchface not registered") } // Always register datafield (if configured) if let datafieldUUID = currentDatafield.datafieldUUID, let datafieldApp = IQApp(uuid: datafieldUUID, store: UUID(), device: device) { - debugGarmin("Garmin: Registered \(appDetailedName(for: datafieldUUID))") + debugGarmin("Registered Garmin \(appDetailedName(for: datafieldUUID))") watchApps.append(datafieldApp) connectIQ?.register(forAppMessages: datafieldApp, delegate: self) } @@ -886,35 +883,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 } connectIQ?.getAppStatus(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) } } @@ -971,15 +968,15 @@ 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 in 2s") + warning(.watch, "Sending to Garmin \(appName) failed, retrying in 2 s") // Retry after delay - SDK may need time after re-registration DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self?.debugGarmin("Garmin: Retrying send to \(appName)") + self?.debugGarmin("Retrying send to Garmin \(appName)") self?.sendMessage(msg, to: app, appName: appName, isRetry: true) } } @@ -997,7 +994,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( @@ -1032,21 +1029,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))") } } @@ -1054,7 +1051,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 @@ -1072,11 +1069,11 @@ 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) - debugGarmin("Garmin: Received message '\(message)' from \(appName)") + debugGarmin("Message received from Garmin \(appName)") // If watch requests status update, send current data via unified path guard let statusString = message as? String, statusString == "status" else { @@ -1115,7 +1112,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) } @@ -1129,13 +1126,13 @@ 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 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() } @@ -1150,7 +1147,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 32a29368e..674ae7865 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 256924385..5c311efe1 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 dd3ac6157..6a8e4ae7f 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 e93080e37..207012910 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/TempPresetsIntentRequest.swift b/Trio/Sources/Shortcuts/TempPresets/TempPresetsIntentRequest.swift index f6e2c2660..2ae0b6754 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,30 +98,26 @@ 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 } } /// 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) } } } From 84c1e6af7d8c78500fbf6ab6d894d28f3ac01789 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:56:29 +0200 Subject: [PATCH 14/48] Clean up Nightscout and remote log messages --- .../View/AppDiagnosticsRootView.swift | 2 +- .../NightscoutConfigStateModel.swift | 15 +- .../BaseNightscoutManager+Subscribers.swift | 2 +- .../Network/Nightscout/NightscoutAPI.swift | 85 ++++----- .../Nightscout/NightscoutManager.swift | 179 +++++++----------- .../Services/Network/TidepoolManager.swift | 56 +++--- .../Network/TidepoolUploadSerializer.swift | 9 +- .../ReleaseNotes/ReleaseNotesService.swift | 6 +- .../RemoteControl/APNSJWTClaims.swift | 2 +- .../RemoteNotificationResponseManager.swift | 11 +- .../TrioRemoteControl+APNS.swift | 6 +- .../TrioRemoteControl+Bolus.swift | 2 +- .../TrioRemoteControl+Helpers.swift | 4 +- .../TrioRemoteControl+MealMutation.swift | 2 +- .../TrioRemoteControl+Override.swift | 6 +- .../TrioRemoteControl+TempTarget.swift | 6 +- .../RemoteControl/TrioRemoteControl.swift | 5 +- .../Telemetry/CrashReportingGate.swift | 7 +- .../Telemetry/TelemetryAttestor.swift | 16 +- .../Services/Telemetry/TelemetryClient.swift | 30 +-- 20 files changed, 184 insertions(+), 267 deletions(-) diff --git a/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift b/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift index d49ec6e62..0384334f4 100644 --- a/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift +++ b/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift @@ -126,7 +126,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/NightscoutConfig/NightscoutConfigStateModel.swift b/Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift index 8bc9d1009..b5eba4e48 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/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift b/Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift index aca1dc476..898675411 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 b27af99a1..8bfffd7e1 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 5faff760d..9f8a880e6 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 } @@ -663,7 +641,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 @@ -693,16 +674,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 { @@ -713,7 +694,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable { self.lastSuggestedDetermination = lastSuggestedDetermination } } catch { - debug(.nightscout, String(describing: error)) + warning(.nightscout, "Device status upload failed", error: error) } } @@ -734,9 +715,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) } } } @@ -748,20 +727,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 } @@ -863,9 +842,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 } @@ -874,26 +854,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 } } @@ -903,10 +883,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) } } @@ -914,10 +891,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) } } @@ -930,10 +904,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) } } @@ -946,10 +917,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) } } @@ -962,10 +930,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) } } @@ -983,9 +948,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) } } @@ -1006,9 +971,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) } } } @@ -1024,10 +987,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 } } @@ -1044,9 +1007,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) } } @@ -1079,9 +1042,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) } } } @@ -1113,9 +1074,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) } } @@ -1136,9 +1097,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) } } } @@ -1174,9 +1133,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) } } @@ -1197,9 +1156,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) } } } @@ -1232,9 +1189,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) } } @@ -1255,9 +1212,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) } } } @@ -1275,9 +1230,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) } } @@ -1298,9 +1253,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) } } } @@ -1318,9 +1271,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) } } @@ -1341,9 +1294,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 f4e7c608b..944490b60 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(.nightscout, "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(.nightscout, "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(.nightscout, "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(.nightscout, "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(.nightscout, "Carb entry deletion synced to Tidepool") case let .failure(error): - debug(.service, "Error synchronizing carbs delete data with Tidepool: \(String(describing: error))") + warning(.nightscout, "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(.nightscout, "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(.nightscout, "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(.nightscout, "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(.nightscout, "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(.nightscout, "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(.nightscout, "Tidepool upload of \(pumpEvents.count) pump events failed", error: error) } } catch { - debug(.service, "Error fetching temp basal entries: \(error)") + warning(.nightscout, "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(.nightscout, "Dose deletion synced to Tidepool") case let .failure(error): - debug(.service, "Error synchronizing Dose delete data: \(String(describing: error))") + warning(.nightscout, "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(.nightscout, "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(.nightscout, "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(.nightscout, "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(.nightscout, "Settings uploaded to Tidepool") case let .failure(error): - debug(.service, "Failed to upload settings to Tidepool: \(error)") + warning(.nightscout, "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(.nightscout, "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 e1168d5cd..81d52e991 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" + .nightscout, + "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(.nightscout, "Tidepool \(pipeline.rawValue) upload dropped before it started, not retried") } return false } diff --git a/Trio/Sources/Services/ReleaseNotes/ReleaseNotesService.swift b/Trio/Sources/Services/ReleaseNotes/ReleaseNotesService.swift index 86f34d717..4d28828c4 100644 --- a/Trio/Sources/Services/ReleaseNotes/ReleaseNotesService.swift +++ b/Trio/Sources/Services/ReleaseNotes/ReleaseNotesService.swift @@ -141,7 +141,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 [] } } @@ -165,7 +165,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 [] } @@ -179,7 +179,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 f3516da7f..5bee6bf02 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 8f9e77844..30d46ed63 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 f7098945b..110bceffc 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 d62920596..6ed91b970 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 abf906b4b..9684dacba 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 af6c70eaa..33b5ccddd 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 2eff57ee9..331be77b1 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 fc40e83a3..03b1b9d36 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 be4da152f..1f8c6621e 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/Telemetry/CrashReportingGate.swift b/Trio/Sources/Services/Telemetry/CrashReportingGate.swift index b2f1e23cf..28ae5a313 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 dbd630f76..818666757 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 7d5bc0a06..3c7d3ef6b 100644 --- a/Trio/Sources/Services/Telemetry/TelemetryClient.swift +++ b/Trio/Sources/Services/Telemetry/TelemetryClient.swift @@ -413,14 +413,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") } } @@ -451,17 +451,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") } @@ -476,17 +476,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") } @@ -498,7 +498,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") } @@ -524,13 +524,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 @@ -538,14 +538,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") } } From 94b0e8b766b776e60b0be30230bcd2bc65098680 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:59:48 +0200 Subject: [PATCH 15/48] Clean up loop, pump and CGM log messages --- Trio/Sources/APS/APSManager.swift | 119 ++++++++--------- Trio/Sources/APS/CGM/AppGroupSource.swift | 2 +- .../APS/CGM/BluetoothTransmitter.swift | 122 ++++++------------ .../APS/CGM/GlucoseSimulatorSource.swift | 2 +- Trio/Sources/APS/CGM/PluginSource.swift | 31 ++--- Trio/Sources/APS/DeviceDataManager.swift | 70 +++++----- Trio/Sources/APS/FetchGlucoseManager.swift | 31 ++--- Trio/Sources/APS/FetchTreatmentsManager.swift | 7 +- Trio/Sources/APS/OpenAPS/OpenAPS.swift | 35 ++--- .../APS/OpenAPSSwift/Profile/Basal.swift | 4 +- .../APS/OpenAPSSwift/Profile/Carbs.swift | 3 +- .../Profile/ProfileGenerator.swift | 4 +- .../APS/OpenAPSSwift/Profile/Targets.swift | 2 +- 13 files changed, 181 insertions(+), 251 deletions(-) diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index 53d783ab2..fc7683350 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -215,10 +215,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) } } } @@ -307,7 +304,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 } @@ -318,7 +315,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 } @@ -340,6 +337,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 @@ -364,7 +362,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 @@ -410,7 +407,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 } } @@ -421,10 +418,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() @@ -491,8 +488,6 @@ final class BaseAPSManager: APSManager, Injectable { } func determineBasal() async throws { - debug(.apsManager, "Start determine basal") - try await calculateAndStoreTDD() var invalidGlucoseError: String? @@ -512,12 +507,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." @@ -527,12 +522,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 } @@ -558,6 +554,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 } @@ -584,7 +584,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 { @@ -615,9 +615,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 } } @@ -642,6 +640,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 @@ -661,31 +660,28 @@ final class BaseAPSManager: APSManager, Injectable { let roundedAmount = pump.roundToSupportedBolusVolume(units: amount) - 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 @@ -697,30 +693,30 @@ 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)") ) } } 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" ) ) @@ -782,7 +778,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 } @@ -795,11 +791,13 @@ final class BaseAPSManager: APSManager, Injectable { if let rate = rateDecimal, let duration = durationInSeconds { try await performBasal(pump: pump, rate: rate, duration: duration) + info(.loop, "Temp basal enacted: \(rate) U/h for \(Int(duration / 60)) min") } // only perform a bolus if smbToDeliver is > 0 if let smb = smbToDeliver, smb.compare(NSDecimalNumber(value: 0)) == .orderedDescending { try await performBolus(pump: pump, smbToDeliver: smb) + info(.loop, "SMB enacted: \(smb) U") } } @@ -828,7 +826,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) } @@ -843,7 +841,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 } @@ -853,7 +851,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 } @@ -863,13 +861,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) } } @@ -1062,7 +1057,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 } } @@ -1101,9 +1096,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, @@ -1273,10 +1266,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 } } @@ -1296,7 +1286,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) } } } @@ -1317,7 +1307,6 @@ final class BaseAPSManager: APSManager, Injectable { } private func processError(_ error: Error) { - warning(.apsManager, "\(error)") lastError.send(error) surfaceErrorIfNeeded(error) } @@ -1348,8 +1337,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" ) } } @@ -1435,10 +1424,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: ()) } } @@ -1451,10 +1439,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: ()) } } @@ -1466,10 +1453,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)) } } @@ -1523,9 +1508,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)) } } @@ -1564,7 +1549,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 5bc555c44..1a0314080 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 99a2dc63c..805b1f89a 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 09f1e8ed9..b2179b355 100644 --- a/Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift +++ b/Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift @@ -102,7 +102,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 d23bebc43..5a7f79dc4 100644 --- a/Trio/Sources/APS/CGM/PluginSource.swift +++ b/Trio/Sources/APS/CGM/PluginSource.swift @@ -159,7 +159,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() } @@ -175,15 +175,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") } } @@ -196,9 +194,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) } @@ -219,10 +218,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 @@ -244,13 +240,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 } } @@ -267,13 +264,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 { @@ -325,10 +317,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 89abaf721..3a2eba3c2 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -95,6 +95,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 let bolusIncrement = Decimal( @@ -122,7 +123,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) } } } @@ -165,6 +166,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) @@ -191,7 +193,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) } } } @@ -272,12 +274,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 } @@ -291,15 +293,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) } } @@ -309,7 +311,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() } @@ -351,11 +353,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 { @@ -364,7 +366,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) { @@ -377,7 +379,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 { @@ -421,15 +423,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) @@ -468,7 +480,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) @@ -535,7 +547,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) } @@ -557,7 +569,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { guard let type = $0.type, type == .tempBasal else { return true } return $0.dose?.unitsPerHour ?? 0 <= Double(settingsManager.pumpSettings.maxBasal) } - 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 @@ -574,7 +586,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) } } @@ -583,14 +595,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)) @@ -605,9 +617,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)) @@ -622,22 +634,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, diff --git a/Trio/Sources/APS/FetchGlucoseManager.swift b/Trio/Sources/APS/FetchGlucoseManager.swift index 187fcc440..8e3a1d8af 100644 --- a/Trio/Sources/APS/FetchGlucoseManager.swift +++ b/Trio/Sources/APS/FetchGlucoseManager.swift @@ -101,7 +101,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { timer.publisher .receive(on: processQueue) .flatMap { [self] _ -> AnyPublisher<[BloodGlucose], Never> in - debug(.nightscout, "FetchGlucoseManager timer heartbeat") if let glucoseSource = self.glucoseSource { return glucoseSource.fetch(self.timer).eraseToAnyPublisher() } else { @@ -109,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()) @@ -124,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() } @@ -154,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() } @@ -195,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)") } } } @@ -233,13 +232,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 @@ -252,8 +255,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() @@ -313,7 +314,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) } @@ -324,11 +325,11 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { let backfillGlucose = newGlucose.filter { $0.dateString <= syncDate } if backfillGlucose.isNotEmpty { - debug(.deviceManager, "Backfilling glucose...") + debug(.cgm, "Backfilling \(backfillGlucose.count) glucose readings") do { try await glucoseStorage.backfillGlucose(backfillGlucose) } catch { - debug(.deviceManager, "Unable to backfill glucose: \(error)") + warning(.cgm, "Backfilling glucose failed", error: error) } } @@ -339,7 +340,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { endBackgroundTaskSafely(&backgroundTaskID, taskName: "Glucose Store and Heartbeat Decision") return } - debug(.deviceManager, "New glucose found") + debug(.cgm, "Storing \(filtered.count) new glucose readings") try await glucoseStorage.storeGlucose(filtered) @@ -510,9 +511,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) } } diff --git a/Trio/Sources/APS/FetchTreatmentsManager.swift b/Trio/Sources/APS/FetchTreatmentsManager.swift index cde4b60e6..969b9c147 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 ca05b7929..e23ff2fec 100644 --- a/Trio/Sources/APS/OpenAPS/OpenAPS.swift +++ b/Trio/Sources/APS/OpenAPS/OpenAPS.swift @@ -96,7 +96,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) } } } @@ -243,8 +243,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 @@ -328,7 +326,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 } @@ -347,7 +345,10 @@ final class OpenAPS { trioCustomOrefVariables: trioCustomOrefVariables ) - 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 @@ -367,10 +368,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.") } } @@ -444,8 +442,6 @@ final class OpenAPS { } func autosense(shouldSmoothGlucose: Bool) async throws -> Autosens? { - debug(.openAPS, "Start autosens") - let context = newContext("autosense") // Perform asynchronous calls in parallel @@ -486,7 +482,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) @@ -494,8 +490,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 @@ -543,15 +537,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") } } @@ -596,10 +590,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 } } @@ -757,7 +748,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/Profile/Basal.swift b/Trio/Sources/APS/OpenAPSSwift/Profile/Basal.swift index 3ae0667e5..fef8e6307 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 fc536a031..acfd6e019 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 75273b54e..d35f6fc90 100644 --- a/Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift +++ b/Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift @@ -87,7 +87,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( @@ -195,7 +195,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 162105723..2899d55d8 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 } } From 6ab7957e1489570d54c238d362f47deb5c3f228e Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:59:49 +0200 Subject: [PATCH 16/48] Clean up storage and lifecycle log messages --- Model/CoreDataStack.swift | 63 ++++++---------- Model/Helper/JSONConverter.swift | 2 +- Model/JSONImporter.swift | 40 ++-------- Trio/Sources/APS/Storage/AlertStorage.swift | 4 +- Trio/Sources/APS/Storage/CarbsStorage.swift | 16 ++-- .../APS/Storage/ContactImageStorage.swift | 14 +--- .../APS/Storage/DeterminationStorage.swift | 20 ++--- Trio/Sources/APS/Storage/GlucoseStorage.swift | 28 +++---- .../Sources/APS/Storage/OverrideStorage.swift | 14 ++-- .../APS/Storage/PumpHistoryStorage.swift | 18 ++--- Trio/Sources/APS/Storage/TDDStorage.swift | 28 +++---- .../APS/Storage/TempTargetsStorage.swift | 12 ++- Trio/Sources/Application/AppDelegate.swift | 16 ++-- Trio/Sources/Application/TrioApp.swift | 19 ++--- .../Helpers/BackgroundTask+Helper.swift | 8 +- Trio/Sources/Helpers/BuildDetails.swift | 4 +- Trio/Sources/Helpers/Bundle+Extensions.swift | 14 ++-- Trio/Sources/Helpers/JSON.swift | 2 +- Trio/Sources/Helpers/MainChartHelper.swift | 10 +-- .../PropertyWrappers/PersistedProperty.swift | 18 ++--- Trio/Sources/Models/BGTargets.swift | 2 +- Trio/Sources/Models/Icons.swift | 2 +- .../HealthKit/HealthKitStateModel.swift | 6 +- .../OnboardingStateModel+Nightscout.swift | 2 +- .../Onboarding/OnboardingStateModel.swift | 8 +- .../SettingsExportStateModel.swift | 41 +++------- .../Services/HealthKit/HealthKitManager.swift | 75 +++++++------------ .../Storage/Cache/UserDefaults+Cache.swift | 2 + .../Services/Storage/FileStorage.swift | 53 +++++++------ 29 files changed, 208 insertions(+), 333 deletions(-) diff --git a/Model/CoreDataStack.swift b/Model/CoreDataStack.swift index dc5ecfe04..d74d80b9c 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 061791dd8..2dd23bf52 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 f0934739d..aa2a76197 100644 --- a/Model/JSONImporter.swift +++ b/Model/JSONImporter.swift @@ -717,83 +717,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" @@ -804,22 +784,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/Sources/APS/Storage/AlertStorage.swift b/Trio/Sources/APS/Storage/AlertStorage.swift index 4e73e9282..82c32df84 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 3b396660c..8e4da1d6e 100644 --- a/Trio/Sources/APS/Storage/CarbsStorage.swift +++ b/Trio/Sources/APS/Storage/CarbsStorage.swift @@ -302,7 +302,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable { guard context.hasChanges else { return } try context.save() } catch { - print(error.localizedDescription) + warning(.storage, "Saving carb entry failed", error: error) } } } @@ -417,12 +417,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) } } } @@ -520,7 +520,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 } @@ -539,7 +539,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(()) @@ -552,13 +552,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 b91ec4bc1..fe55ac2f1 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 346b549b3..b29ccee86 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 } } @@ -196,9 +188,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 27b2753bf..625107ea4 100644 --- a/Trio/Sources/APS/Storage/GlucoseStorage.swift +++ b/Trio/Sources/APS/Storage/GlucoseStorage.swift @@ -154,10 +154,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 { @@ -196,7 +193,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 @@ -263,7 +260,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) ?? [] @@ -283,7 +279,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 } @@ -350,9 +346,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) } } } @@ -384,7 +378,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable { fetchedDate = date } } catch { - debugPrint("Fetch error: \(DebuggingIdentifiers.failed) \(error)") + warning(.storage, "Fetching latest glucose date failed", error: error) } } @@ -406,7 +400,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) } } @@ -871,7 +865,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 } @@ -887,11 +881,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) } } } @@ -917,7 +909,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 fa7be3b88..17cc017e0 100644 --- a/Trio/Sources/APS/Storage/OverrideStorage.swift +++ b/Trio/Sources/APS/Storage/OverrideStorage.swift @@ -214,9 +214,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) } } @@ -232,7 +230,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 } @@ -241,11 +239,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) } } } @@ -329,7 +325,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 bf759f4fd..effceb32d 100644 --- a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift +++ b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift @@ -198,8 +198,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) @@ -213,9 +213,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 @@ -266,7 +266,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: @@ -290,8 +290,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" ) } @@ -331,10 +331,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 cb8e67588..15867231d 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 ceb9dffd1..1e2031361 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 1a0377ad0..a597e09e3 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 dcffb6c9b..c0cb28685 100644 --- a/Trio/Sources/Application/TrioApp.swift +++ b/Trio/Sources/Application/TrioApp.swift @@ -216,14 +216,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 @@ -242,21 +239,21 @@ extension Notification.Name { } catch { importErrors .append(String(localized: "Failed to import glucose history.")) - debug(.coreData, "❌ Failed to import JSON-based Glucose History: \(error)") + warning(.storage, "Importing legacy glucose history failed", error: error) } do { try await importer.importPumpHistoryIfNeeded() } catch { importErrors.append(String(localized: "Failed to import pump history.")) - debug(.coreData, "❌ Failed to import JSON-based Pump History: \(error)") + warning(.storage, "Importing legacy pump history failed", error: error) } do { try await importer.importCarbHistoryIfNeeded() } catch { importErrors.append(String(localized: "Failed to import algorithm data.")) - debug(.coreData, "❌ Failed to import JSON-based Carb History: \(error)") + warning(.storage, "Importing legacy carb history failed", error: error) } do { @@ -266,7 +263,7 @@ extension Notification.Name { .append( String(localized: "Migration of JSON-based OpenAPS Determination Data failed: \(error.localizedDescription)") ) - debug(.coreData, "❌ Failed to import JSON-based OpenAPS Determination Data: \(error)") + warning(.storage, "Importing legacy determinations failed", error: error) } initState.migrationErrors = importErrors @@ -405,7 +402,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 { diff --git a/Trio/Sources/Helpers/BackgroundTask+Helper.swift b/Trio/Sources/Helpers/BackgroundTask+Helper.swift index 871708569..bb2067c75 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 eafc814d8..bf58e01da 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 28e7733f5..80b5de66e 100644 --- a/Trio/Sources/Helpers/Bundle+Extensions.swift +++ b/Trio/Sources/Helpers/Bundle+Extensions.swift @@ -16,9 +16,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 } @@ -31,7 +29,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 } @@ -54,16 +52,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" } @@ -75,7 +71,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 2066f6659..4458332e6 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 301289053..12ed35693 100644 --- a/Trio/Sources/Helpers/MainChartHelper.swift +++ b/Trio/Sources/Helpers/MainChartHelper.swift @@ -130,12 +130,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 @@ -148,9 +146,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 2b7776e6d..c9feb97d1 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/Models/BGTargets.swift b/Trio/Sources/Models/BGTargets.swift index 2c0c23935..bfd6fef78 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 363459659..9ca417f6d 100644 --- a/Trio/Sources/Models/Icons.swift +++ b/Trio/Sources/Models/Icons.swift @@ -29,7 +29,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/HealthKit/HealthKitStateModel.swift b/Trio/Sources/Modules/HealthKit/HealthKitStateModel.swift index 534119ee4..7234be304 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/Onboarding/OnboardingStateModel+Nightscout.swift b/Trio/Sources/Modules/Onboarding/OnboardingStateModel+Nightscout.swift index 078529d12..716563483 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 b4d70dcea..5ff458330 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/SettingsExport/SettingsExportStateModel.swift b/Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift index b15c4a794..3562eb045 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. @@ -971,10 +964,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 { @@ -1035,7 +1026,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)")) @@ -1046,11 +1037,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 { @@ -1186,7 +1175,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)")) @@ -1199,8 +1188,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) @@ -1215,7 +1202,7 @@ extension SettingsExport { } } - debug(.default, "Found \(mealPresetData.count) meal presets") + debug(.settings, "Exporting \(mealPresetData.count) meal presets") if !mealPresetData.isEmpty { for mealPreset in mealPresetData { @@ -1251,7 +1238,7 @@ extension SettingsExport { } } } catch { - debug(.default, "Failed to fetch meal presets: \(error)") + warning(.settings, "Fetching meal presets for export failed", error: error) } } @@ -1274,43 +1261,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/Services/HealthKit/HealthKitManager.swift b/Trio/Sources/Services/HealthKit/HealthKitManager.swift index c6fb6ab3d..19ae6d48e 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/Storage/Cache/UserDefaults+Cache.swift b/Trio/Sources/Services/Storage/Cache/UserDefaults+Cache.swift index 5ec730a3b..ecf793579 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 4a34e3db7..f73465e32 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 From c0932dff9602281b9f4987e6c4681674e29377c3 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:04:05 +0200 Subject: [PATCH 17/48] Clean up bolus, treatment and alert log messages --- .../AdjustmentsStateModel+Overrides.swift | 48 +++++----------- .../AdjustmentsStateModel+TempTargets.swift | 57 +++++++------------ .../Adjustments/AdjustmentsStateModel.swift | 6 +- .../View/AdjustmentsRootView.swift | 2 + .../View/Overrides/EditOverrideForm.swift | 2 +- .../View/TempTargets/AddTempTargetForm.swift | 6 +- .../View/TempTargets/EditTempTargetForm.swift | 8 +-- .../AlgorithmAdvancedSettingsStateModel.swift | 7 +-- .../AutosensSettingsStateModel.swift | 9 +-- .../AutotuneConfigStateModel.swift | 12 ++-- .../BasalProfileEditorProvider.swift | 2 +- .../BasalProfileEditorStateModel.swift | 8 +-- .../CGMSettings/CGMSettingsStateModel.swift | 2 +- .../CGMSettings/View/CGMSetupView.swift | 2 +- .../Calibrations/CalibrationsStateModel.swift | 5 +- .../CarbRatioEditorStateModel.swift | 4 +- .../ConfigEditor/ConfigEditorProvider.swift | 2 +- .../ContactImage/ContactImageStateModel.swift | 16 +++--- .../DynamicSettingsStateModel.swift | 2 +- .../View/Components/AlarmAudioSection.swift | 2 +- .../HistoryStateModel+CarbEditing.swift | 15 +++-- .../HistoryStateModel+Carbs.swift | 5 +- .../HistoryStateModel+Glucose.swift | 10 +--- .../HistoryStateModel+Insulin.swift | 14 ++--- .../Modules/Home/HomeStateModel+CGM.swift | 2 +- .../HomeStateModel+QuickPickTreatments.swift | 10 ++-- .../HomeStateModel+Setup/BatterySetup.swift | 2 +- .../Home/HomeStateModel+Setup/CarbSetup.swift | 4 +- .../CurrentTDDSetup.swift | 2 +- .../DeterminationSetup.swift | 4 +- .../HomeStateModel+Setup/ForecastSetup.swift | 7 +-- .../HomeStateModel+Setup/GlucoseSetup.swift | 4 +- .../GlucoseTargetSetup.swift | 4 +- .../HomeStateModel+Setup/OverrideSetup.swift | 10 ++-- .../PumpHistorySetup.swift | 4 +- .../TempTargetSetup.swift | 8 +-- .../Sources/Modules/Home/HomeStateModel.swift | 5 +- .../Modules/Home/View/HomeRootView.swift | 2 +- .../ISFEditor/ISFEditorStateModel.swift | 7 +-- .../Main/View/MainMigrationErrorView.swift | 2 +- .../PumpConfig/View/PumpSetupView.swift | 2 +- .../Modules/Settings/SettingsStateModel.swift | 4 +- .../StatStateModel+Setup/AreaChartSetup.swift | 2 +- .../BolusStatsSetup.swift | 2 +- .../StatStateModel+Setup/LoopChartSetup.swift | 4 +- .../StatStateModel+Setup/MealStatsSetup.swift | 2 +- .../StackedChartSetup.swift | 2 +- .../Stat/StatStateModel+Setup/TDDSetup.swift | 2 +- .../Sources/Modules/Stat/StatStateModel.swift | 10 +--- .../TargetsEditorStateModel.swift | 7 +-- .../Treatments/TreatmentsStateModel.swift | 52 ++++++----------- .../View/MealPreset/MealPresetView.swift | 2 +- .../Adjustments/AdjustmentManager.swift | 10 ++-- .../Alerts/CriticalAlertAlarmScheduler.swift | 12 ++-- .../Alerts/CriticalAlertAudioPlayer.swift | 10 ++-- .../Alerts/GlucoseAlertCoordinator.swift | 2 +- .../Services/Alerts/TrioAlertManager.swift | 20 +++---- .../TrioUserNotificationAlertScheduler.swift | 2 +- .../BolusCalculationManager.swift | 37 +++--------- .../AlertPermissionsChecker.swift | 2 +- .../UserNotificationsManager.swift | 12 ++-- Trio/Sources/Views/LogShareRow.swift | 2 +- 62 files changed, 207 insertions(+), 316 deletions(-) diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift index 452bb44b4..bf27545dd 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) } } @@ -115,6 +113,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() @@ -122,10 +121,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) } } @@ -164,10 +160,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) } } @@ -180,10 +173,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) } } } @@ -196,9 +186,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) } } @@ -209,10 +197,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) } } @@ -235,10 +220,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) } } } @@ -254,7 +236,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) } } @@ -272,9 +254,7 @@ extension Adjustments.StateModel { activeOverrideName = overrideToEdit.name ?? String(localized: "Custom Override") } } catch { - debugPrint( - "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to set active Override: \(error)" - ) + warning(.treatments, "Loading active override failed", error: error) } } @@ -296,9 +276,7 @@ extension Adjustments.StateModel { activeOverrideName = overrideToEdit.name ?? String(localized: "Custom Override") } } 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 c15839979..a12a2c372 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) } } @@ -57,9 +54,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) } } @@ -76,10 +71,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) } } } @@ -91,7 +83,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 [] } } @@ -137,10 +129,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, @@ -167,7 +158,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) @@ -185,13 +176,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) } } @@ -207,10 +196,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, @@ -234,10 +222,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, @@ -271,6 +258,7 @@ extension Adjustments.StateModel { if viewContext.hasChanges { try viewContext.save() } + info(.treatments, "Temp target preset \"\(tempTargetToEnact.name ?? "Custom")\" started") updateLatestTempTargetConfiguration() @@ -288,7 +276,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) } } @@ -341,10 +329,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) } } @@ -373,9 +358,7 @@ extension Adjustments.StateModel { activeTempTargetName = tempTargetToEdit.name ?? String(localized: "Custom Temp Target") } } 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 8d41a05e6..979a5787a 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift @@ -145,9 +145,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) } } } @@ -163,7 +161,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 0ce055251..219a9c255 100644 --- a/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift +++ b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift @@ -160,6 +160,7 @@ extension Adjustments { Task { // Save cancelled Override in OverrideRunStored Entity // Cancel ALL active Override + info(.treatments, "Override stopped") await state.disableAllActiveOverrides(createOverrideRunEntry: true) } } @@ -174,6 +175,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 1113f0447..a2a3b19a8 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift @@ -545,7 +545,7 @@ struct EditOverrideForm: View { ) } } 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 566f417c9..875e5afd9 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift @@ -280,7 +280,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: { @@ -301,7 +301,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: { @@ -344,7 +344,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 6bf105117..2265a7186 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/EditTempTargetForm.swift @@ -329,9 +329,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 } @@ -373,7 +373,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/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift b/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift index 02e15e1ed..744a75374 100644 --- a/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift +++ b/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift @@ -70,16 +70,13 @@ extension AlgorithmAdvancedSettings { .sink { _ in let settings = self.provider.settings() self.insulinActionCurve = settings.insulinActionCurve + info(.settings, "Duration of insulin action set to \(settings.insulinActionCurve) h") Task.detached(priority: .low) { do { - 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)" - ) + warning(.nightscout, "Uploading profile after insulin action change failed", error: error) } } diff --git a/Trio/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift b/Trio/Sources/Modules/AutosensSettings/AutosensSettingsStateModel.swift index 9446eecbf..bd19c62a8 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 dd4028711..5616d1828 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 { storage.save(basals, as: OpenAPS.Settings.basalProfile) - 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.storage.save(basals, as: OpenAPS.Settings.basalProfile) - 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 834c6891e..bec8d1116 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 8e805278b..c5b7d74c7 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift @@ -111,6 +111,7 @@ extension BasalProfileEditor { self.syncInProgress = false switch completion { case .finished: + 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) } @@ -122,23 +123,22 @@ extension BasalProfileEditor { Task.detached(priority: .low) { 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) { 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 e1a57da75..02844ca7f 100644 --- a/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift +++ b/Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift @@ -155,7 +155,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/CGMSetupView.swift b/Trio/Sources/Modules/CGMSettings/View/CGMSetupView.swift index 0194c0f3c..c5a0b1ce1 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/Calibrations/CalibrationsStateModel.swift b/Trio/Sources/Modules/Calibrations/CalibrationsStateModel.swift index 1e234621e..c86d08cf3 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 9b2565614..cd9896aa6 100644 --- a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift +++ b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift @@ -90,6 +90,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 { @@ -100,10 +101,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/ConfigEditor/ConfigEditorProvider.swift b/Trio/Sources/Modules/ConfigEditor/ConfigEditorProvider.swift index 05e5b958e..4a0f2dd17 100644 --- a/Trio/Sources/Modules/ConfigEditor/ConfigEditorProvider.swift +++ b/Trio/Sources/Modules/ConfigEditor/ConfigEditorProvider.swift @@ -17,7 +17,7 @@ extension ConfigEditor { } guard let data = value.data(using: .utf8), (try? JSONSerialization.jsonObject(with: data, options: [])) != nil else { - warning(.service, "Invalid JSON") + warning(.settings, "Config file \(file) not saved, invalid JSON") return } storage.save(value, as: file) diff --git a/Trio/Sources/Modules/ContactImage/ContactImageStateModel.swift b/Trio/Sources/Modules/ContactImage/ContactImageStateModel.swift index 8b679f449..4f7e13ea2 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 aa485b64d..d94cdd631 100644 --- a/Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift +++ b/Trio/Sources/Modules/DynamicSettings/DynamicSettingsStateModel.swift @@ -63,7 +63,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/GlucoseAlerts/View/Components/AlarmAudioSection.swift b/Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift index 3004c796b..5412ae060 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/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift b/Trio/Sources/Modules/History/HistoryStateModel+Deletion/HistoryStateModel+CarbEditing.swift index e51b02be9..05ec1a066 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 831496852..ab4c0709f 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 1e21cc363..83c651dce 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 d8755ec23..5fa602449 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/Home/HomeStateModel+CGM.swift b/Trio/Sources/Modules/Home/HomeStateModel+CGM.swift index 5b4c9e79f..3e287634a 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 3d17c7dbc..01f9fbc0f 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 741a83399..9ea7d8dcd 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 3db06068a..e98a0d778 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 e4c56af11..386e41749 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 87c4a38ad..36d1a9c6d 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 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 3a2c74573..ef169737c 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 98f418951..81f5f0650 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) } } @@ -90,7 +90,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 92bd8dcab..fbcdd480f 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 3e6237aa3..f83e2fb5d 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/PumpHistorySetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/PumpHistorySetup.swift index 911cdc542..1ec236a08 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 d6fa59172..6d01d7c7c 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 469886844..3616c503a 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -452,7 +452,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) } } @@ -661,9 +661,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) diff --git a/Trio/Sources/Modules/Home/View/HomeRootView.swift b/Trio/Sources/Modules/Home/View/HomeRootView.swift index e84cac530..c76f6690e 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView.swift @@ -215,7 +215,7 @@ extension Home { if notificationsDisabled != $0 { notificationsDisabled = $0 if notificationsDisabled { - debug(.default, "notificationsDisabled") + debug(.alerts, "Notifications are disabled in iOS settings") } } } diff --git a/Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift b/Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift index ae1ccbb65..eb2acc87a 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 c0a873ceb..d13007ae7 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/PumpConfig/View/PumpSetupView.swift b/Trio/Sources/Modules/PumpConfig/View/PumpSetupView.swift index 511d03193..0e1ad1ab2 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 7d266b7d0..9772effca 100644 --- a/Trio/Sources/Modules/Settings/SettingsStateModel.swift +++ b/Trio/Sources/Modules/Settings/SettingsStateModel.swift @@ -93,13 +93,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/Stat/StatStateModel+Setup/AreaChartSetup.swift b/Trio/Sources/Modules/Stat/StatStateModel+Setup/AreaChartSetup.swift index 4638738b9..1862ceb2d 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 c7c0b7800..01379dfab 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 7afd52fd5..80ceef132 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 1d0192e34..a5c63763d 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 f19be29a6..d05ae34bb 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 43e365266..e3197ce79 100644 --- a/Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift +++ b/Trio/Sources/Modules/Stat/StatStateModel+Setup/TDDSetup.swift @@ -25,7 +25,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 dc9e2fd81..a24a155b9 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 0d73b2bfd..1e1b785e0 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 970cbff7e..8091db590 100644 --- a/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift +++ b/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift @@ -213,15 +213,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()`. @@ -247,12 +242,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. @@ -272,7 +264,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. @@ -394,7 +386,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 } @@ -495,7 +487,6 @@ extension Treatments { func invokeTreatmentsTask() { Task { - debug(.bolusState, "invokeTreatmentsTask fired") await MainActor.run { self.addButtonPressed = true } @@ -535,8 +526,6 @@ extension Treatments { // MARK: - Insulin private func handleInsulin(isExternal: Bool) async { - debug(.bolusState, "handleInsulin fired") - if !isExternal { await addPumpInsulin() } else { @@ -644,10 +633,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 @@ -678,11 +668,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 @@ -717,6 +708,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 { @@ -726,7 +718,7 @@ extension Treatments { try await apsManager.determineBasalSync() } } catch { - debug(.default, "\(DebuggingIdentifiers.failed) Failed to save carbs: \(error)") + warning(.treatments, "Saving meal failed", error: error) } } @@ -740,7 +732,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 @@ -774,13 +766,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() @@ -792,7 +780,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() @@ -818,7 +805,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) } } @@ -868,7 +855,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) } } @@ -939,19 +926,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 @@ -966,7 +948,7 @@ extension Treatments.StateModel { if let simDetermination = simulated { evBG = Decimal(simDetermination.eventualBG ?? 0) minPredBG = simDetermination.minPredBGFromReason ?? 0 - debugPrint("\(DebuggingIdentifiers.inProgress) minPredBG: \(minPredBG)") + debug(.bolus, "Forecast simulated, min predicted BG \(minPredBG) mg/dL") } } @@ -1036,7 +1018,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 d65285077..107831bef 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 c954e1dfd..c4f53f103 100644 --- a/Trio/Sources/Services/Adjustments/AdjustmentManager.swift +++ b/Trio/Sources/Services/Adjustments/AdjustmentManager.swift @@ -137,14 +137,14 @@ final class BaseAdjustmentManager: AdjustmentManager, Injectable, @unchecked Sen waitForUpload: Bool ) async throws -> AdjustmentOutcome { let outcome = try await serializer.run { try await self.commitOverride(activating: preset) } - debug(.service, "Override activated from \(source.rawValue): \(outcome.logDescription)") + info(.treatments, "Override activated from \(source.rawValue): \(outcome.logDescription)") await applyOverrideSideEffects(source: source, waitForUpload: waitForUpload) return outcome } @discardableResult func cancelOverride(source: AdjustmentSource, waitForUpload: Bool) async throws -> AdjustmentOutcome { let outcome = try await serializer.run { try await self.commitOverride(activating: nil) } - debug(.service, "Override cancelled from \(source.rawValue): \(outcome.logDescription)") + info(.treatments, "Override cancelled from \(source.rawValue): \(outcome.logDescription)") await applyOverrideSideEffects(source: source, waitForUpload: waitForUpload) return outcome } @@ -215,14 +215,14 @@ final class BaseAdjustmentManager: AdjustmentManager, Injectable, @unchecked Sen waitForUpload: Bool ) async throws -> AdjustmentOutcome { let (outcome, orefTarget) = try await serializer.run { try await self.commitTempTarget(activating: preset) } - debug(.service, "Temp target activated from \(source.rawValue): \(outcome.logDescription)") + info(.treatments, "Temp target activated from \(source.rawValue): \(outcome.logDescription)") await applyTempTargetSideEffects(orefTarget: orefTarget, source: source, waitForUpload: waitForUpload) return outcome } @discardableResult func cancelTempTarget(source: AdjustmentSource, waitForUpload: Bool) async throws -> AdjustmentOutcome { let (outcome, orefTarget) = try await serializer.run { try await self.commitTempTarget(activating: nil) } - debug(.service, "Temp target cancelled from \(source.rawValue): \(outcome.logDescription)") + info(.treatments, "Temp target cancelled from \(source.rawValue): \(outcome.logDescription)") await applyTempTargetSideEffects(orefTarget: orefTarget, source: source, waitForUpload: waitForUpload) return outcome } @@ -325,7 +325,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 bf95c5a04..11cbcc314 100644 --- a/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift +++ b/Trio/Sources/Services/Alerts/CriticalAlertAlarmScheduler.swift @@ -45,7 +45,7 @@ final class CriticalAlertAlarmScheduler { do { _ = try await AlarmManager.shared.requestAuthorization() } catch let authorizationError { - error(.service, "AlarmKit authorization request failed", error: authorizationError) + warning(.alerts, "AlarmKit authorization request failed", error: authorizationError) } #endif } @@ -95,11 +95,11 @@ final class CriticalAlertAlarmScheduler { Task { [weak self] in do { _ = try await AlarmManager.shared.schedule(id: id, configuration: configuration) - info(.service, "Scheduled AlarmKit alarm \(id.uuidString) for \(identifier.value)") + info(.alerts, "AlarmKit alarm scheduled for \(identifier.value)") } catch let scheduleError { - error( - .service, - "Failed to schedule AlarmKit alarm for \(identifier.value) — falling back to audio", + warning( + .alerts, + "Scheduling AlarmKit alarm for \(identifier.value) failed, falling back to audio", error: scheduleError ) self?.alarmsByAlert.removeValue(forKey: identifier) @@ -120,7 +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) - info(.service, "Cancelled AlarmKit alarm \(id.uuidString) for \(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 00f87bf2c..896e1b092 100644 --- a/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift +++ b/Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift @@ -34,7 +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 { - error(.service, "Neither \(soundName) nor critical.caf found in main bundle; audio fallback unavailable") + error(.alerts, "Critical alert sound \(soundName) and fallback sound missing, audio fallback unavailable") return } @@ -57,14 +57,14 @@ final class CriticalAlertAudioPlayer { p.volume = 1.0 p.prepareToPlay() guard p.play() else { - error(.service, "AVAudioPlayer.play() returned false for \(url.lastPathComponent)") + error(.alerts, "Critical alert audio did not start for \(url.lastPathComponent)") try? session.setActive(false, options: [.notifyOthersOnDeactivation]) return } player = p - info(.service, "Started critical-alert audio playback (duration=\(String(format: "%.2f", p.duration))s)") + info(.alerts, "Critical alert audio started, sound length \(String(format: "%.2f", p.duration)) s") } catch let playbackError { - error(.service, "Failed to start audio playback", error: playbackError) + error(.alerts, "Critical alert audio playback failed", error: playbackError) } } @@ -84,7 +84,7 @@ final class CriticalAlertAudioPlayer { vibrationTimer = nil volumeBooster.restore() try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) - info(.service, "Stopped critical-alert audio playback") + debug(.alerts, "Critical alert audio stopped") } } diff --git a/Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift b/Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift index 389d8f3e3..513ee3f38 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 dc824e958..bbfd8c3c2 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") return } effective = Self.applyDeviceSeverityConfig(config, entry: entry, to: alert) @@ -252,11 +246,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 { @@ -340,7 +334,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: @@ -381,7 +375,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 93d375890..a2352f0ba 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/BolusCalculator/BolusCalculationManager.swift b/Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift index fe9a4983a..6bd0ca3ed 100644 --- a/Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift +++ b/Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift @@ -96,7 +96,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 } @@ -366,10 +366,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 } @@ -381,57 +377,46 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable { func calculateInsulin(input: CalculationInput) async -> CalculationResult { // 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)") } } @@ -440,36 +425,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, @@ -513,11 +497,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/Notifications/AlertPermissionsChecker.swift b/Trio/Sources/Services/Notifications/AlertPermissionsChecker.swift index 5a0e79b02..3ec4a1813 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/UserNotifications/UserNotificationsManager.swift b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift index 1ffc989dc..783e15e4c 100644 --- a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift +++ b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift @@ -99,7 +99,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In guard let error else { return } - print(error) + warning(.alerts, "Setting app badge failed", error: error) } } return @@ -117,7 +117,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In guard let error else { return } - print(error) + warning(.alerts, "Setting app badge failed", error: error) } } } @@ -174,7 +174,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) } } @@ -187,15 +187,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) } } } diff --git a/Trio/Sources/Views/LogShareRow.swift b/Trio/Sources/Views/LogShareRow.swift index 7aca1c086..e020cd470 100644 --- a/Trio/Sources/Views/LogShareRow.swift +++ b/Trio/Sources/Views/LogShareRow.swift @@ -98,7 +98,7 @@ struct LogShareRow: View { showShareSheet = true } catch { isPreparing = false - warning(.service, "Could not prepare logs for sharing: \(error)") + warning(.ui, "Preparing logs for sharing failed", error: error) if let onFailure { onFailure() } else { From 96fd02740a8227476b3ceaa40cef686b5d48ba7a Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:42:09 +0200 Subject: [PATCH 18/48] Retire legacy log categories --- CONTRIBUTING.md | 15 +++++++ .../OpenAPSSwift/AlgorithmLoggingShim.swift | 12 +----- .../Logger/DeviceLog/DeviceLogStore.swift | 4 +- Trio/Sources/Logger/Export/LogExport.swift | 2 +- Trio/Sources/Logger/Export/OSLogCapture.swift | 2 +- Trio/Sources/Logger/Logger.swift | 12 +----- Trio/Sources/Logger/Store/AppLogStore.swift | 8 ++-- .../AppVersionChecker/AppVersionChecker.swift | 11 +---- .../Services/Network/TidepoolManager.swift | 42 +++++++++---------- .../Network/TidepoolUploadSerializer.swift | 4 +- TrioTests/Logging/AppLogStoreTests.swift | 3 +- TrioTests/Logging/DeviceLogStoreTests.swift | 3 +- TrioTests/Logging/TrioLoggerTests.swift | 21 +++++----- 13 files changed, 63 insertions(+), 76 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b48ba873d..1ab1613b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,21 @@ Trio is a collaborative project, and contributions of all kinds are welcome. Whe - 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. diff --git a/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift b/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift index 79bea54bf..c54f47314 100644 --- a/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift +++ b/Trio/Sources/APS/OpenAPSSwift/AlgorithmLoggingShim.swift @@ -22,6 +22,7 @@ import Foundation case bolus case treatments case nightscout + case tidepool case remote case healthKit case alerts @@ -32,17 +33,6 @@ import Foundation case telemetry case ui case lifecycle - // Legacy, removed once all call sites are migrated - case `default` - case service - case businessLogic - case openAPS - case deviceManager - case apsManager - case remoteControl - case bolusState - case watchManager - case coreData } } diff --git a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift index b3cf7edc2..5a1fb95eb 100644 --- a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift +++ b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift @@ -151,7 +151,7 @@ final class DeviceLogStore: @unchecked Sendable { if preflight(storageURL) { open() } else { - warning(.deviceManager, "Device log store unavailable, keeping entries in memory") + warning(.storage, "Device log store unavailable, keeping entries in memory") } } @@ -179,7 +179,7 @@ final class DeviceLogStore: @unchecked Sendable { do { try FileManager.default.moveItem(at: source, to: destination) } catch { - warning(.deviceManager, "Could not move device log file aside: \(error)") + warning(.storage, "Moving unreadable device log aside failed", error: error) } } } diff --git a/Trio/Sources/Logger/Export/LogExport.swift b/Trio/Sources/Logger/Export/LogExport.swift index a7bb11618..738289af5 100644 --- a/Trio/Sources/Logger/Export/LogExport.swift +++ b/Trio/Sources/Logger/Export/LogExport.swift @@ -202,7 +202,7 @@ final class LogExport: Injectable { } } } catch { - warning(.service, "Could not read determinations for the log file: \(error)") + warning(.storage, "Reading determinations for the log file failed", error: error) return [] } } diff --git a/Trio/Sources/Logger/Export/OSLogCapture.swift b/Trio/Sources/Logger/Export/OSLogCapture.swift index 4e3ddf8cf..a0ce54e5e 100644 --- a/Trio/Sources/Logger/Export/OSLogCapture.swift +++ b/Trio/Sources/Logger/Export/OSLogCapture.swift @@ -47,7 +47,7 @@ enum OSLogCapture { } return lines } catch { - warning(.service, "Could not read Bluetooth traces: \(error)") + warning(.lifecycle, "Reading Bluetooth traces failed", error: error) return [] } } diff --git a/Trio/Sources/Logger/Logger.swift b/Trio/Sources/Logger/Logger.swift index 8d0313ae4..691bdfbb9 100644 --- a/Trio/Sources/Logger/Logger.swift +++ b/Trio/Sources/Logger/Logger.swift @@ -69,6 +69,7 @@ enum Logger { case bolus case treatments case nightscout + case tidepool case remote case healthKit case alerts @@ -79,17 +80,6 @@ enum Logger { case telemetry case ui case lifecycle - // Legacy, removed once all call sites are migrated - case `default` - case service - case businessLogic - case openAPS - case deviceManager - case apsManager - case remoteControl - case bolusState - case watchManager - case coreData var name: String { rawValue.capitalizingFirstLetter() diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index 82da25d65..73dc11b43 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -189,7 +189,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { do { try context.execute(request) } catch { - Logger.Category.coreData.osLogger.error("App log insert failed: \(String(describing: error), privacy: .public)") + Logger.Category.storage.osLogger.error("App log insert failed: \(String(describing: error), privacy: .public)") } } } @@ -254,7 +254,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { do { try context.execute(NSBatchDeleteRequest(fetchRequest: request)) } catch { - Logger.Category.coreData.osLogger.error("App log purge failed: \(String(describing: error), privacy: .public)") + Logger.Category.storage.osLogger.error("App log purge failed: \(String(describing: error), privacy: .public)") } } @@ -283,7 +283,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { if let container = loadContainer(storageURL) { open(container) } else { - Logger.Category.coreData.osLogger.error("App log store unavailable, keeping records in memory") + Logger.Category.storage.osLogger.error("App log store unavailable, keeping records in memory") } } @@ -305,7 +305,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { do { try FileManager.default.moveItem(at: source, to: destination) } catch { - Logger.Category.coreData.osLogger + Logger.Category.storage.osLogger .error("Could not move app log file aside: \(String(describing: error), privacy: .public)") } } diff --git a/Trio/Sources/Services/AppVersionChecker/AppVersionChecker.swift b/Trio/Sources/Services/AppVersionChecker/AppVersionChecker.swift index db430986f..a7ee701bb 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/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index 944490b60..8b033d54c 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 { - warning(.nightscout, "Failed to set up Tidepool upload controllers", error: 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 { - warning(.nightscout, "Failed to load pending carbs for Tidepool upload", error: 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: - info(.nightscout, "Uploaded \(chunk.count) carb entries to Tidepool") + 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): - warning(.nightscout, "Tidepool upload of \(chunk.count) carb entries failed", error: error) + warning(.tidepool, "Tidepool upload of \(chunk.count) carb entries failed", error: error) } } } @@ -352,9 +352,9 @@ extension BaseTidepoolManager { } switch result { case .success: - debug(.nightscout, "Carb entry deletion synced to Tidepool") + debug(.tidepool, "Carb entry deletion synced to Tidepool") case let .failure(error): - warning(.nightscout, "Tidepool carb entry deletion failed", error: error) + warning(.tidepool, "Tidepool carb entry deletion failed", error: error) } } } @@ -379,7 +379,7 @@ extension BaseTidepoolManager { do { events = try await pumpHistoryStorage.getPumpHistoryNotYetUploadedToTidepool() } catch { - warning(.nightscout, "Failed to load pending pump history for Tidepool upload", error: error) + warning(.tidepool, "Failed to load pending pump history for Tidepool upload", error: error) return } guard !events.isEmpty else { return } @@ -440,7 +440,7 @@ extension BaseTidepoolManager { return result } - debug(.nightscout, "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() { @@ -476,13 +476,13 @@ extension BaseTidepoolManager { } switch doseResult { case .success: - info(.nightscout, "Uploaded \(insulinDoseEvents.count) dose entries to Tidepool") + 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): - warning(.nightscout, "Tidepool upload of \(insulinDoseEvents.count) dose entries failed", error: error) + warning(.tidepool, "Tidepool upload of \(insulinDoseEvents.count) dose entries failed", error: error) } guard await uploadSerializer.isCurrent(generation) else { return } @@ -492,14 +492,14 @@ extension BaseTidepoolManager { } switch pumpResult { case .success: - info(.nightscout, "Uploaded \(pumpEvents.count) pump events to Tidepool") + info(.tidepool, "Uploaded \(pumpEvents.count) pump events to Tidepool") let pumpEventsToMark = events.filter { $0.type.mapEventTypeToPumpEventType() != nil } await updateInsulinAsUploaded(pumpEventsToMark) case let .failure(error): - warning(.nightscout, "Tidepool upload of \(pumpEvents.count) pump events failed", error: error) + warning(.tidepool, "Tidepool upload of \(pumpEvents.count) pump events failed", error: error) } } catch { - warning(.nightscout, "Failed to prepare dose entries for Tidepool upload", error: error) + warning(.tidepool, "Failed to prepare dose entries for Tidepool upload", error: error) } } @@ -544,9 +544,9 @@ extension BaseTidepoolManager { } switch result { case .success: - debug(.nightscout, "Dose deletion synced to Tidepool") + debug(.tidepool, "Dose deletion synced to Tidepool") case let .failure(error): - warning(.nightscout, "Tidepool dose deletion failed", error: error) + warning(.tidepool, "Tidepool dose deletion failed", error: error) } } } @@ -723,7 +723,7 @@ extension BaseTidepoolManager { let manualGlucose = try await glucoseStorage.getManualGlucoseNotYetUploadedToTidepool() glucose = cgmGlucose + manualGlucose } catch { - warning(.nightscout, "Failed to load pending glucose for Tidepool upload", error: error) + warning(.tidepool, "Failed to load pending glucose for Tidepool upload", error: error) return } guard !glucose.isEmpty else { return } @@ -737,11 +737,11 @@ extension BaseTidepoolManager { switch result { case .success: - info(.nightscout, "Uploaded \(chunk.count) glucose values to Tidepool") + 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): - warning(.nightscout, "Tidepool upload of \(chunk.count) glucose values failed", error: error) + warning(.tidepool, "Tidepool upload of \(chunk.count) glucose values failed", error: error) } } } @@ -820,9 +820,9 @@ extension BaseTidepoolManager { } switch result { case .success: - info(.nightscout, "Settings uploaded to Tidepool") + info(.tidepool, "Settings uploaded to Tidepool") case let .failure(error): - warning(.nightscout, "Tidepool settings upload failed", error: error) + warning(.tidepool, "Tidepool settings upload failed", error: error) } } } @@ -873,7 +873,7 @@ extension BaseTidepoolManager { ), let bgTargets: BGTargets = storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self) else { - warning(.nightscout, "Tidepool settings upload skipped: therapy settings could not be loaded") + 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 81d52e991..d0c4d7282 100644 --- a/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift +++ b/Trio/Sources/Services/Network/TidepoolUploadSerializer.swift @@ -89,7 +89,7 @@ actor TidepoolUploadSerializer { let age = Date().timeIntervalSince(start) guard age > watchdogLimit else { return } warning( - .nightscout, + .tidepool, "Tidepool \(headPipeline?.rawValue ?? "unknown") upload stuck for \(Int(age)) s, restarting upload chain" ) generation += 1 @@ -102,7 +102,7 @@ actor TidepoolUploadSerializer { private func begin(_ pipeline: TidepoolUploadPipeline, generation: Int) -> Bool { guard isCurrent(generation) else { if !pipeline.coalesces { - warning(.nightscout, "Tidepool \(pipeline.rawValue) upload dropped before it started, not retried") + warning(.tidepool, "Tidepool \(pipeline.rawValue) upload dropped before it started, not retried") } return false } diff --git a/TrioTests/Logging/AppLogStoreTests.swift b/TrioTests/Logging/AppLogStoreTests.swift index b75f2c269..030ecca35 100644 --- a/TrioTests/Logging/AppLogStoreTests.swift +++ b/TrioTests/Logging/AppLogStoreTests.swift @@ -54,8 +54,9 @@ import UIKit ) } + // Generous: the main actor is busy when the whole suite runs private static func waitUntil(_ condition: () async -> Bool) async -> Bool { - for _ in 0 ..< 100 { + for _ in 0 ..< 400 { if await condition() { return true } try? await Task.sleep(nanoseconds: 50_000_000) } diff --git a/TrioTests/Logging/DeviceLogStoreTests.swift b/TrioTests/Logging/DeviceLogStoreTests.swift index 22bed2547..ecfc1c978 100644 --- a/TrioTests/Logging/DeviceLogStoreTests.swift +++ b/TrioTests/Logging/DeviceLogStoreTests.swift @@ -43,8 +43,9 @@ import UIKit } } + // Generous: the main actor is busy when the whole suite runs private static func waitUntil(_ condition: () async -> Bool) async -> Bool { - for _ in 0 ..< 100 { + for _ in 0 ..< 400 { if await condition() { return true } try? await Task.sleep(nanoseconds: 50_000_000) } diff --git a/TrioTests/Logging/TrioLoggerTests.swift b/TrioTests/Logging/TrioLoggerTests.swift index 36b3eaf7d..eab75d5fe 100644 --- a/TrioTests/Logging/TrioLoggerTests.swift +++ b/TrioTests/Logging/TrioLoggerTests.swift @@ -17,15 +17,15 @@ import Testing let token = UUID().uuidString let firstLine = UInt(#line) + 2 let records = capture { - debug(.openAPS, "debug \(token)") - info(.service, "info \(token)") + debug(.algorithm, "debug \(token)") + info(.loop, "info \(token)") warning(.nightscout, "warning \(token)") - error(.coreData, "error \(token)") - fault(.default, "fault \(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) == ["OpenAPS", "Service", "Nightscout", "CoreData", "Default"]) + #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)") @@ -33,17 +33,16 @@ import Testing @Test("Error returns to the caller") func testErrorReturns() { var reached = false - let records = capture { - error(.service, "error returns", description: "detail") + _ = capture { + error(.loop, "error returns", description: "detail") reached = true } #expect(reached) - #expect(records.last?.message.contains("detail") == true) } @Test("Debug without console output still reaches the sink") func testPrintToConsoleFalse() { let records = capture { - debug(.service, "quiet", printToConsole: false) + debug(.ui, "quiet", printToConsole: false) } #expect(records.last?.message == "quiet") } @@ -54,7 +53,7 @@ import Testing let records = capture { elapsed = clock.measure { for index in 0 ..< 10000 { - debug(.businessLogic, "throughput \(index)") + debug(.loop, "throughput \(index)") } } } @@ -69,7 +68,7 @@ import Testing sink.accept(LogRecord( timestamp: Date(), level: .debug, - category: "Default", + category: "Loop", message: "\(index)", file: "File.swift", function: "f()", From fac0573537d57191dfff1894b047948bff43cff4 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:05:53 +0200 Subject: [PATCH 19/48] Rename Share Logs to Share Trio Logs --- Trio/Sources/Localizations/Main/Localizable.xcstrings | 3 +++ Trio/Sources/Modules/Settings/View/SettingsRootView.swift | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 4a0b7126d..4ef934c79 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -268687,6 +268687,9 @@ }, "Share Pump Logs" : { + }, + "Share Trio Logs" : { + }, "Shared Secret" : { "localizations" : { diff --git a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift index fe4cd914f..d5189d0ba 100644 --- a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift +++ b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift @@ -292,7 +292,7 @@ extension Settings { header: Text("Support & Community"), content: { LogShareRow( - title: "Share Logs", + title: "Share Trio Logs", kinds: state.logKinds, logExport: state.logExport, onFailure: showLogError From 96cd81fd00505a5636665ce87081e5797be16029 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:13:46 +0200 Subject: [PATCH 20/48] Add logging stress tests --- Trio.xcodeproj/project.pbxproj | 12 + .../LoggingRobustnessStressTests.swift | 1344 +++++++++++++++++ TrioTests/Logging/LoggingStressFixtures.swift | 398 +++++ .../Logging/LoggingVolumeStressTests.swift | 708 +++++++++ 4 files changed, 2462 insertions(+) create mode 100644 TrioTests/Logging/LoggingRobustnessStressTests.swift create mode 100644 TrioTests/Logging/LoggingStressFixtures.swift create mode 100644 TrioTests/Logging/LoggingVolumeStressTests.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index f0f05b5d7..84ab5f000 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -146,6 +146,7 @@ 38AEE75225F022080013F05B /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75125F022080013F05B /* SettingsManager.swift */; }; 38AEE75725F0F18E0013F05B /* CarbsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */; }; 38B4E1780D264CEBEB5DBCB3 /* LogExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF80D3953C10DCDF5D80C27 /* LogExportTests.swift */; }; + 3F30764ED6567CB6B1DF68C1 /* LoggingStressFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510C461CD60549BDF7E57BE9 /* LoggingStressFixtures.swift */; }; 4ABD323927D718C0177C26B5 /* OSLogCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */; }; 4E3B6212E43A3E90DD735B30 /* AppLog.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */; }; 536B4DB89FCFE9188F4DFE5D /* RedactionPatterns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCB6F0602E069F5B4F83EDC /* RedactionPatterns.swift */; }; @@ -911,6 +912,8 @@ DD30BA1A2E08AB9F00DA677C /* CarbImpactParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */; }; DD32CF982CC82463003686D6 /* TrioRemoteControl+Bolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */; }; DD32CF9A2CC8247B003686D6 /* TrioRemoteControl+Meal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */; }; + EAE1104EF2313F9A0041C746 /* LoggingVolumeStressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A4F4087B27F7D5819A37272 /* LoggingVolumeStressTests.swift */; }; + F07C663CC423264E609D706C /* LoggingRobustnessStressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258BD00B3EAADF1A62EFC884 /* LoggingRobustnessStressTests.swift */; }; F0D6F2237EB1FB4862D0D4D7 /* AppLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39062D177D83C69CAFC9B2AC /* AppLogStore.swift */; }; F6854A961CB1CF1105757936 /* LogShareRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EB8FEFF04CF8327EE83D84 /* LogShareRow.swift */; }; FC83BDFB7C330BFD6A4DECFC /* WatchLogIngestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */; }; @@ -1281,6 +1284,7 @@ 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogStoreTests.swift; sourceTree = ""; }; 223EC0494F55A91E3EA69EF4 /* TreatmentsStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TreatmentsStateModel.swift; sourceTree = ""; }; 24AF0AA0328EA5E4016BD4D6 /* LaunchID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchID.swift; sourceTree = ""; }; + 258BD00B3EAADF1A62EFC884 /* LoggingRobustnessStressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingRobustnessStressTests.swift; sourceTree = ""; }; 260A8D718D2B6656B925FD08 /* DeviceLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogStore.swift; sourceTree = ""; }; 2AD22C985B79A2F0D2EA3D9D /* PumpConfigRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigRootView.swift; sourceTree = ""; }; 2EEC5C2927FA4C5DA61A1A40 /* QuickPickTreatmentsConfigProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPickTreatmentsConfigProvider.swift; sourceTree = ""; }; @@ -1556,6 +1560,7 @@ 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 = ""; }; + 510C461CD60549BDF7E57BE9 /* LoggingStressFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingStressFixtures.swift; sourceTree = ""; }; 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogIngestTests.swift; sourceTree = ""; }; 581516A32BCED84A00BF67D7 /* DebuggingIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingIdentifiers.swift; sourceTree = ""; }; 581516A82BCEEDF800BF67D7 /* NSPredicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSPredicates.swift; sourceTree = ""; }; @@ -1592,6 +1597,7 @@ 5A2325512BFCBF55003518CA /* NightscoutUploadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadView.swift; sourceTree = ""; }; 5A2325532BFCBF65003518CA /* NightscoutFetchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutFetchView.swift; sourceTree = ""; }; 5A2325572BFCC168003518CA /* NightscoutConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutConnectView.swift; sourceTree = ""; }; + 5A4F4087B27F7D5819A37272 /* LoggingVolumeStressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingVolumeStressTests.swift; sourceTree = ""; }; 5C018D1680307A31C9ED7120 /* CGMSettingsStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CGMSettingsStateModel.swift; sourceTree = ""; }; 5D5B4F8B4194BB7E260EF251 /* ConfigEditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorStateModel.swift; sourceTree = ""; }; 5E12FE0E2EBC0000000000A1 /* LibreLoop.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LibreLoop.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -3934,6 +3940,9 @@ 5516A8F6B694AF44C243B882 /* WatchLogIngestTests.swift */, 704720C410E0C4F7D10B184B /* RedactorTests.swift */, CAF80D3953C10DCDF5D80C27 /* LogExportTests.swift */, + 510C461CD60549BDF7E57BE9 /* LoggingStressFixtures.swift */, + 5A4F4087B27F7D5819A37272 /* LoggingVolumeStressTests.swift */, + 258BD00B3EAADF1A62EFC884 /* LoggingRobustnessStressTests.swift */, ); path = Logging; sourceTree = ""; @@ -6525,6 +6534,9 @@ FC83BDFB7C330BFD6A4DECFC /* WatchLogIngestTests.swift in Sources */, 2C9535979A6E92EE0EC83255 /* RedactorTests.swift in Sources */, 38B4E1780D264CEBEB5DBCB3 /* LogExportTests.swift in Sources */, + 3F30764ED6567CB6B1DF68C1 /* LoggingStressFixtures.swift in Sources */, + EAE1104EF2313F9A0041C746 /* LoggingVolumeStressTests.swift in Sources */, + F07C663CC423264E609D706C /* LoggingRobustnessStressTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TrioTests/Logging/LoggingRobustnessStressTests.swift b/TrioTests/Logging/LoggingRobustnessStressTests.swift new file mode 100644 index 000000000..b9cd5b2c6 --- /dev/null +++ b/TrioTests/Logging/LoggingRobustnessStressTests.swift @@ -0,0 +1,1344 @@ +import CoreData +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) + withKnownIssue("Seen once in five runs; records are stamped with the wall clock", isIntermittent: true) { + #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) + + withKnownIssue("DeviceLogStore.entries returns [] while the store is closed, so buffered entries miss the export") { + #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 + } + withKnownIssue("A store that opens read-only drops every insert without falling back to memory") { + #expect(stored == records) + #expect(exported == 20) + } when: { + corruption == .readOnlyFile + } + } + + @Test( + "Corrupt device log store never reaches PersistentDeviceLog's fatalError", + arguments: Corruption.allCases + ) func corruptDeviceLogStore(_ corruption: Corruption) async throws { + // A read-only store crashes the host in PersistentDeviceLog.log; opt in to reproduce + guard corruption != .readOnlyFile || ProcessInfo.processInfo.environment["TRIO_STRESS_CRASH"] == "1" else { return } + 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) + withKnownIssue("Entries buffered in memory never reach entries(in:) or the export") { + #expect(errors == 0) + #expect(stored.count == 20) + #expect(exported == 20) + } when: { + corruption == .readOnlyDirectory + } + } + + @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()) } + withKnownIssue( + "preflight loads a read-only store, then PersistentDeviceLog.log throws 'Cannot set metadata in read-only store'" + ) { + #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)" + ) + withKnownIssue("recover() moves the store aside without retrying the load once data is available") { + #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) { + withKnownIssue("Core Data truncates strings at the first NUL") { + #expect(roundTripped.message == original.message) + } when: { + original.message.contains("\u{0}") + } + } + } + + @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)") + withKnownIssue("NSBatchInsertRequest rejects the whole batch when one message fails string validation") { + #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)) + withKnownIssue("Equal timestamps come back in SQLite's order, not the order they were logged", isIntermittent: true) { + #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 = "0123456789abcdef0123456789abcdef01234567" + let redactor = Redactor(exactValues: [ + Redactor.ExactValue(value: "https://\(host)/", kind: .url), + Redactor.ExactValue(value: "https://\(host)", kind: .url), + Redactor.ExactValue(value: host, kind: .host), + Redactor.ExactValue(value: secret, kind: .secret), + Redactor.ExactValue(value: device, kind: .device) + ]) + 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 scheme", "GET HTTPS://third-party.example.test/v1?token=abc123", "third-party.example.test"), + ("lowercase bearer", "authorization: bearer abc.DEF-123_xyz", "abc.def-123_xyz"), + ("uppercase bearer", "AUTHORIZATION: BEARER abc.DEF-123_xyz", "abc.def-123_xyz") + ] + ) func encodedVariants(name _: String, input: String, leaked: String) { + let redactor = Redactor(exactValues: [Redactor.ExactValue(value: "synthetic secret/0001!", kind: .secret)]) + let output = redactor.redact(input).lowercased() + withKnownIssue("Exact values match only their raw form; URL and Bearer patterns are case-sensitive") { + #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 redactor = Redactor(exactValues: devices.map { Redactor.ExactValue(value: $0, kind: .device) }) + 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 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) { + withKnownIssue("The Dexcom TID pattern accepts any six alphanumerics, including plain words") { + #expect(Redactor(exactValues: []).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: []) + return (size, clock.measure { _ = redactor.redact(run) }.seconds) + } + let probe = String(repeating: "a1", count: 8192) + let perPattern = RedactionPatterns.all.enumerated().map { index, pattern in + let elapsed = clock.measure { _ = Redactor(exactValues: [], patterns: [pattern]).redact(probe) } + return "#\(index) \(String(format: "%.3f", elapsed.seconds)) s" + } + let spaced = String(repeating: "lorem ipsum ", count: 5 * 1_048_576 / 12) + let spacedTime = clock.measure { _ = Redactor(exactValues: []).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); 16 KB per pattern \(perPattern)" + ) + withKnownIssue("The email pattern rescans an unbroken run from every start position, so cost grows quadratically") { + #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() + 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))") + } + } + + 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 000000000..05fff6155 --- /dev/null +++ b/TrioTests/Logging/LoggingStressFixtures.swift @@ -0,0 +1,398 @@ +import CoreData +import Darwin +import Foundation +import LoopKit + +@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() + } +} + +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 linesPerCycle = 60 + static let burstsPerDay = 4 + static let burstSize = 1000 + static let pumpPairsPerCycle = 34 + static let cgmPerCycle = 2 + static let retiredDays = 30 + static let forecastLength = 12 + + 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 + ] + + 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) -> [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 ..< burstsPerDay).map { _ in Int.random(in: 0 ..< cyclesPerDay, using: &rng) }) + var records: [LogRecord] = [] + records.reserveCapacity(cyclesPerDay * linesPerCycle + burstsPerDay * 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), + 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 ..< 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 ..< 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) -> 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: 5 ... 30, 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) -> [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 ..< 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)) + 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)) + } + 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 ..< 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 + } + + 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 determination = OrefDetermination(context: context) + determination.id = UUID() + determination.deliverAt = date + determination.timestamp = date + determination.enacted = rng.chance(0.9) + determination.glucose = NSDecimalNumber(value: Int.random(in: 60 ... 250, using: &rng)) + determination.iob = NSDecimalNumber(value: Double(Int.random(in: -50 ... 600, using: &rng)) / 100) + determination.cob = Int16.random(in: 0 ... 80, using: &rng) + determination.rate = NSDecimalNumber(value: Double(Int.random(in: 0 ... 300, using: &rng)) / 100) + determination.duration = 30 + determination.eventualBG = NSDecimalNumber(value: Int.random(in: 60 ... 250, using: &rng)) + determination.reason = "COB: 0, Dev: 4, BGI: -1, ISF: 54, CR: 9, Target: 100, minPredBG 98, IOBpredBG 101" + for type in ["iob", "cob", "uam", "zt"] { + let forecast = Forecast(context: context) + forecast.id = UUID() + forecast.date = date + forecast.type = type + forecast.orefDetermination = determination + var value = Int32.random(in: 80 ... 180, using: &rng) + for position in 0 ..< forecastLength { + let point = ForecastValue(context: context) + point.index = Int32(position) + point.value = value + point.forecast = forecast + 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 000000000..d36b4c9af --- /dev/null +++ b/TrioTests/Logging/LoggingVolumeStressTests.swift @@ -0,0 +1,708 @@ +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() + + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("LoggingVolumeStress", isDirectory: true) + // 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? + + 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 + } + + func reset() { + appLog = nil + deviceLog = 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 + 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) + 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", [ + "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", [ + "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) + await store.flush() + let ageDuration = Self.clock.now - ageStart + let afterAge = appRows(url) + Fixtures.report("appLog.agePurge", [ + "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) >= Date().addingTimeInterval(-LogRetentionPolicy.default.maxAge - 60)) + #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 + 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) + 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", [ + "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", [ + "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) -> LogExport { + LogExport(appLog: appLog, deviceLogStore: deviceLog, pump: StubManager(), cgm: StubCGMManager()) + } + + @Test("App log store ingests 90 days and purges to the cap, then to 7 days") func appLogIngest() async throws { + await Stores.shared.reset() + _ = try await Stores.shared.preparedAppLog() + } + + @Test("Device log store ingests 90 days and purges to 7 days on open") func deviceLogIngest() async throws { + _ = try await Stores.shared.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 and redacted") func exports() async throws { + let appLog = try await Stores.shared.preparedAppLog() + let deviceLog = try await Stores.shared.preparedDeviceLog() + let appURL = await Stores.shared.appLogURL + let deviceURL = await Stores.shared.deviceLogURL + let export = Self.makeExport(appLog, deviceLog) + + 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 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 = "\(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)") + + func expectCount(_ source: String, rows: (Date) -> Int) { + let upper = rows(before.addingTimeInterval(-span)) + let lower = rows(after.addingTimeInterval(-span)) + let found = analysis.counts[source] ?? 0 + #expect(found >= lower && found <= upper, "\(name) \(source): \(found) not in \(lower)...\(upper)") + } + let expected: Set + switch kind { + case .all: expected = ["Trio", "Pump", "CGM"] + case .pump: expected = ["Pump"] + case .cgm: expected = ["CGM"] + } + #expect(Set(analysis.counts.keys) == expected, "\(name)") + if expected.contains("Trio") { + expectCount("Trio") { Self.appRows(appURL, Self.since($0)).count } + } + if expected.contains("Pump") { + expectCount("Pump") { Self.deviceRows(deviceURL, Self.since($0, manager: Fixtures.pumpManager)).count } + } + if expected.contains("CGM") { + expectCount("CGM") { Self.deviceRows(deviceURL, Self.since($0, manager: Fixtures.cgmManager)).count } + } + if kind == .all, days == 7 { + #expect(Fixtures.seconds(duration) < 60) + } + } + } + + 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.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) + defer { LogExport.discard(archive) } + Fixtures.report("export.makeFile-all-7d", [ + "seconds": String(format: "%.2f", Fixtures.seconds(Self.clock.now - start)), + "zip": Fixtures.megabytes(Fixtures.fileSize(archive)) + ]) + } + + @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 + } + + let redactor = Redactor(exactValues: [ + Redactor.ExactValue(value: Fixtures.siteURL, kind: .url), + Redactor.ExactValue(value: Fixtures.siteHost, kind: .host), + Redactor.ExactValue(value: Fixtures.pumpDevice, kind: .device), + Redactor.ExactValue(value: Fixtures.cgmDevice, kind: .device), + Redactor.ExactValue(value: Fixtures.retiredDevice, kind: .device) + ]) + let start = Self.clock.now + var leaked = 0 + for chunk in chunks { + let redacted = redactor.redact(chunk) + leaked += Fixtures.planted.filter { redacted.contains($0) }.count + } + let seconds = Fixtures.seconds(Self.clock.now - start) + 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) + + // Per-stage cost on a 10 MB sample, with and without non-ASCII text + let sample = Array(chunks.prefix(160)) + let ascii = sample.map { String(String.UnicodeScalarView($0.unicodeScalars.filter(\.isASCII))) } + let sampleSize = Double(sample.reduce(0) { $0 + $1.utf8.count }) / Self.mb + func rate(_ texts: [String], _ redactor: Redactor) -> String { + let start = Self.clock.now + texts.forEach { _ = redactor.redact($0) } + return String(format: "%.1f", sampleSize / Fixtures.seconds(Self.clock.now - start)) + } + let exactValues = [Redactor.ExactValue(value: Fixtures.pumpDevice, kind: .device)] + var stages: [String] = [ + "exactOnly:\(rate(sample, Redactor(exactValues: exactValues, patterns: [])))", + "exactOnlyASCII:\(rate(ascii, Redactor(exactValues: exactValues, patterns: [])))", + "allPatternsASCII:\(rate(ascii, Redactor(exactValues: [])))" + ] + for (index, pattern) in RedactionPatterns.all.enumerated() { + stages.append("pattern\(index):\(rate(sample, Redactor(exactValues: [], patterns: [pattern])))") + } + // Anchoring the email local part at a token start avoids rescanning every word suffix + let anchoredEmail = + try NSRegularExpression(pattern: #"(?= 4) + } + } + + @Test("A 20-byte BLE packet in hex survives redaction") func blePacketNotSecret() { + let packet = "Sending data - Name: CMD_7, OpCode: 7, Encoded data: a5a50c01c86e2a4b9d0f3e771122334455665a5a" + withKnownIssue("Redactor's bare 40-hex secret pattern also matches 20-byte packet dumps") { + #expect(Redactor(exactValues: []).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": "\(4 * Fixtures.forecastLength)", + "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() + } +} From 2428660649382497bcec92742e4ebac960eac775 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:37:12 +0200 Subject: [PATCH 21/48] Fix log store robustness bugs --- .../Logger/DeviceLog/DeviceLogStore.swift | 33 +++++- Trio/Sources/Logger/Store/AppLogStore.swift | 89 ++++++++++++--- TrioTests/Logging/AppLogStoreTests.swift | 37 +++++++ TrioTests/Logging/DeviceLogStoreTests.swift | 9 +- .../LoggingRobustnessStressTests.swift | 102 +++++++++++------- 5 files changed, 211 insertions(+), 59 deletions(-) diff --git a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift index 5a1fb95eb..4c47a05c8 100644 --- a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift +++ b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift @@ -108,7 +108,18 @@ final class DeviceLogStore: @unchecked Sendable { await withCheckedContinuation { continuation in queue.async { guard let deviceLog = self.deviceLog else { - continuation.resume(returning: []) + continuation.resume(returning: self.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 @@ -146,6 +157,11 @@ final class DeviceLogStore: @unchecked Sendable { 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) { @@ -185,7 +201,8 @@ final class DeviceLogStore: @unchecked Sendable { } static func preflight(_ url: URL) -> Bool { - autoreleasepool { + guard FileManager.default.isWritableStore(at: url) else { return false } + return autoreleasepool { guard let modelURL = Bundle(for: PersistentDeviceLog.self).url(forResource: "DeviceLog", withExtension: "momd"), let model = NSManagedObjectModel(contentsOf: modelURL) else { return false } @@ -196,10 +213,20 @@ final class DeviceLogStore: @unchecked Sendable { var loadError: Error? container.loadPersistentStores { _, error in loadError = error } let coordinator = container.persistentStoreCoordinator + let isReadOnly = coordinator.persistentStores.contains(where: \.isReadOnly) for store in coordinator.persistentStores { try? coordinator.remove(store) } - return loadError == nil + return loadError == nil && !isReadOnly + } + } +} + +extension FileManager { + 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/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index 73dc11b43..4656a21ac 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -40,6 +40,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { private var lastPurge: Date? private var isCheckingProtectedData = false private var didMoveStoreAside = false + private var didReopenAfterFailure = false init( storageURL: URL = AppLogStore.defaultStorageURL, @@ -121,9 +122,10 @@ final class AppLogStore: LogSink, @unchecked Sendable { } } + // Exports wait on these, so they run ahead of background logging load func flush() async { await withCheckedContinuation { continuation in - queue.async { + queue.async(qos: .userInitiated, flags: .enforceQoS) { self.flushPending() continuation.resume() } @@ -132,7 +134,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { func records(in window: DateInterval, minimumLevel: LogLevel) async -> [LogRecord] { await withCheckedContinuation { continuation in - queue.async { + queue.async(qos: .userInitiated, flags: .enforceQoS) { self.flushPending() continuation.resume(returning: self.fetch(in: window, minimumLevel: minimumLevel)) } @@ -162,38 +164,66 @@ final class AppLogStore: LogSink, @unchecked Sendable { } return state.records } - if !batch.isEmpty { - insert(batch, into: context) + 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() } - private func insert(_ batch: [LogRecord], into context: NSManagedObjectContext) { + // 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": record.category, - "message": record.message, - "file": record.file, - "function": record.function, + "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 }) - context.performAndWait { - do { - try context.execute(request) - } catch { + 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 } @@ -278,6 +308,11 @@ final class AppLogStore: LogSink, @unchecked Sendable { 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) { @@ -287,6 +322,28 @@ final class AppLogStore: LogSink, @unchecked Sendable { } } + 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() @@ -326,15 +383,15 @@ final class AppLogStore: LogSink, @unchecked Sendable { } static func loadContainer(_ url: URL) -> NSPersistentContainer? { - guard let model else { return nil } + 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 } - guard loadError == nil else { - let coordinator = container.persistentStoreCoordinator + let coordinator = container.persistentStoreCoordinator + guard loadError == nil, !coordinator.persistentStores.contains(where: \.isReadOnly) else { for store in coordinator.persistentStores { try? coordinator.remove(store) } diff --git a/TrioTests/Logging/AppLogStoreTests.swift b/TrioTests/Logging/AppLogStoreTests.swift index 030ecca35..f455ca46b 100644 --- a/TrioTests/Logging/AppLogStoreTests.swift +++ b/TrioTests/Logging/AppLogStoreTests.swift @@ -138,6 +138,43 @@ import UIKit #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() diff --git a/TrioTests/Logging/DeviceLogStoreTests.swift b/TrioTests/Logging/DeviceLogStoreTests.swift index ecfc1c978..2c23d2198 100644 --- a/TrioTests/Logging/DeviceLogStoreTests.swift +++ b/TrioTests/Logging/DeviceLogStoreTests.swift @@ -84,15 +84,18 @@ import UIKit isProtectedDataAvailable: { true } ) - #expect(await Self.waitUntil { preflight.callCount == 2 }) + #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).isEmpty) + #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).count == 2 }) + #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)$"#) diff --git a/TrioTests/Logging/LoggingRobustnessStressTests.swift b/TrioTests/Logging/LoggingRobustnessStressTests.swift index b9cd5b2c6..33f858ab5 100644 --- a/TrioTests/Logging/LoggingRobustnessStressTests.swift +++ b/TrioTests/Logging/LoggingRobustnessStressTests.swift @@ -569,10 +569,8 @@ private extension Duration { let entries = await store.entries(in: Self.everything) let text = try await Self.export(.all, appLog: InMemoryLogSink(), deviceLogStore: store) - withKnownIssue("DeviceLogStore.entries returns [] while the store is closed, so buffered entries miss the export") { - #expect(entries.count == 20) - #expect(Self.occurrences(of: "[send] pending ", in: Self.body(text)) == 20) - } + #expect(entries.count == 20) + #expect(Self.occurrences(of: "[send] pending ", in: Self.body(text)) == 20) } // MARK: Corrupt stores @@ -639,20 +637,65 @@ private extension Duration { case .readOnlyFile: break } - withKnownIssue("A store that opens read-only drops every insert without falling back to memory") { - #expect(stored == records) - #expect(exported == 20) - } when: { - corruption == .readOnlyFile + #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 { - // A read-only store crashes the host in PersistentDeviceLog.log; opt in to reproduce - guard corruption != .readOnlyFile || ProcessInfo.processInfo.environment["TRIO_STRESS_CRASH"] == "1" else { return } let directory = try Self.makeDirectory() let url = directory.appendingPathComponent("logs/DeviceLog.sqlite") defer { @@ -692,13 +735,9 @@ private extension Duration { #expect(moved.count <= 1) #expect(preflight.successes.get() <= 1) - withKnownIssue("Entries buffered in memory never reach entries(in:) or the export") { - #expect(errors == 0) - #expect(stored.count == 20) - #expect(exported == 20) - } when: { - corruption == .readOnlyDirectory - } + #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 { @@ -709,11 +748,7 @@ private extension Duration { try? FileManager.default.removeItem(at: directory) } try Self.corrupt(url, .readOnlyFile) { try Self.populateDeviceLog(at: $0, count: 10, start: Date()) } - withKnownIssue( - "preflight loads a read-only store, then PersistentDeviceLog.log throws 'Cannot set metadata in read-only store'" - ) { - #expect(!DeviceLogStore.preflight(url)) - } + #expect(!DeviceLogStore.preflight(url)) } @Test("A load failure that races an unlock does not move a healthy store aside") func unlockRaceKeepsHealthyStore() async throws { @@ -748,12 +783,10 @@ private extension Duration { "STRESS: unlock race: app seeds \(appSeeds.count)/100 moved \(Self.movedAside(appURL).count), " + "device seeds \(deviceSeeds.count)/100 moved \(Self.movedAside(deviceURL).count)" ) - withKnownIssue("recover() moves the store aside without retrying the load once data is available") { - #expect(Self.movedAside(appURL).isEmpty) - #expect(appSeeds.count == 100) - #expect(Self.movedAside(deviceURL).isEmpty) - #expect(deviceSeeds.count == 100) - } + #expect(Self.movedAside(appURL).isEmpty) + #expect(appSeeds.count == 100) + #expect(Self.movedAside(deviceURL).isEmpty) + #expect(deviceSeeds.count == 100) } // MARK: Protected data flapping @@ -856,11 +889,8 @@ private extension Duration { #expect(stored.count == records.count) for (original, roundTripped) in zip(records, stored) { - withKnownIssue("Core Data truncates strings at the first NUL") { - #expect(roundTripped.message == original.message) - } when: { - original.message.contains("\u{0}") - } + // 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}")) } } @@ -884,9 +914,7 @@ private extension Duration { await store.flush() let stored = await store.records(in: Self.everything, minimumLevel: .debug) print("STRESS: lone surrogate batch kept \(stored.count)/\(records.count)") - withKnownIssue("NSBatchInsertRequest rejects the whole batch when one message fails string validation") { - #expect(stored.filter { $0.message.hasPrefix("good ") }.count == records.count - 1) - } + #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 { From a233d377e84c3fb78151a94a3b7c60e11ea31303 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:08:22 +0200 Subject: [PATCH 22/48] Redact exact stored sensitive values only --- Trio.xcodeproj/project.pbxproj | 8 +- Trio/Sources/Logger/Export/LogExport.swift | 74 +++--- .../Logger/Export/RedactionPatterns.swift | 38 --- Trio/Sources/Logger/Export/Redactor.swift | 188 +++++++++++--- .../Logger/Export/SensitiveValues.swift | 203 ++++++++++++++++ TrioTests/Logging/LogExportTests.swift | 12 +- .../LoggingRobustnessStressTests.swift | 58 ++--- TrioTests/Logging/LoggingStressFixtures.swift | 13 + .../Logging/LoggingVolumeStressTests.swift | 71 ++---- TrioTests/Logging/RedactorTests.swift | 230 ++++++++++++++---- 10 files changed, 657 insertions(+), 238 deletions(-) delete mode 100644 Trio/Sources/Logger/Export/RedactionPatterns.swift create mode 100644 Trio/Sources/Logger/Export/SensitiveValues.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 84ab5f000..7522aafea 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -149,7 +149,7 @@ 3F30764ED6567CB6B1DF68C1 /* LoggingStressFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510C461CD60549BDF7E57BE9 /* LoggingStressFixtures.swift */; }; 4ABD323927D718C0177C26B5 /* OSLogCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */; }; 4E3B6212E43A3E90DD735B30 /* AppLog.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */; }; - 536B4DB89FCFE9188F4DFE5D /* RedactionPatterns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCB6F0602E069F5B4F83EDC /* RedactionPatterns.swift */; }; + 536B4DB89FCFE9188F4DFE5D /* SensitiveValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCB6F0602E069F5B4F83EDC /* SensitiveValues.swift */; }; 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */; }; 7BA97B74F25D6A1F5D853EFB /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F602845F4994CA0A57286EE6 /* ShareSheet.swift */; }; 91AE5F35FC765DFE4C1A2589 /* AppLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */; }; @@ -1234,7 +1234,7 @@ 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SyaiKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0A1E8522E7684C6AA742F6E4 /* HomeStateModel+QuickPickTreatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeStateModel+QuickPickTreatments.swift"; sourceTree = ""; }; - 0CCB6F0602E069F5B4F83EDC /* RedactionPatterns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactionPatterns.swift; sourceTree = ""; }; + 0CCB6F0602E069F5B4F83EDC /* SensitiveValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensitiveValues.swift; sourceTree = ""; }; 0F0205BEC3117B5D00022C30 /* LogRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRecord.swift; sourceTree = ""; }; 10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadSerializer.swift; sourceTree = ""; }; 110AEDE02C5193D100615CC9 /* BolusIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusIntent.swift; sourceTree = ""; }; @@ -3905,7 +3905,7 @@ 366F24AC292DBC7BC55CF196 /* LogFileWriter.swift */, 7FBFFB8EF42D847AFFDA6C05 /* LogFileHeader.swift */, 3B0396B90CF23D28D2FDCD78 /* Redactor.swift */, - 0CCB6F0602E069F5B4F83EDC /* RedactionPatterns.swift */, + 0CCB6F0602E069F5B4F83EDC /* SensitiveValues.swift */, ); path = Export; sourceTree = ""; @@ -6409,13 +6409,13 @@ 4ABD323927D718C0177C26B5 /* OSLogCapture.swift in Sources */, F0D6F2237EB1FB4862D0D4D7 /* AppLogStore.swift in Sources */, A8354E8A31A4F482D0B649F1 /* Redactor.swift in Sources */, - 536B4DB89FCFE9188F4DFE5D /* RedactionPatterns.swift in Sources */, 7BA97B74F25D6A1F5D853EFB /* ShareSheet.swift in Sources */, 4E3B6212E43A3E90DD735B30 /* AppLog.xcdatamodeld in Sources */, B0D5791570373A23CD562E97 /* LogFileHeader.swift in Sources */, BF0FA8AAE98B4080B961F69F /* LogFileWriter.swift in Sources */, 96310848E7A3468607C340E9 /* LogExport.swift in Sources */, F6854A961CB1CF1105757936 /* LogShareRow.swift in Sources */, + 536B4DB89FCFE9188F4DFE5D /* SensitiveValues.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Trio/Sources/Logger/Export/LogExport.swift b/Trio/Sources/Logger/Export/LogExport.swift index 738289af5..551f07483 100644 --- a/Trio/Sources/Logger/Export/LogExport.swift +++ b/Trio/Sources/Logger/Export/LogExport.swift @@ -3,6 +3,7 @@ import Foundation import LoopKit import OSLog import Swinject +import TidepoolServiceKit import UIKit final class LogExport: Injectable { @@ -17,6 +18,7 @@ final class LogExport: Injectable { @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" @@ -34,20 +36,29 @@ final class LogExport: Injectable { private let managers: (@MainActor() -> (pump: DeviceManager?, cgm: DeviceManager?))? private let traceSubsystems: [String: [String]] private let includesAlgorithm: Bool + private let storedValues: SensitiveValues init(resolver: Resolver) { appLog = { TrioLogger.sink } managers = nil traceSubsystems = OSLogCapture.subsystems includesAlgorithm = true + storedValues = SensitiveValues() injectServices(resolver) } - init(appLog: any LogSink, deviceLogStore: DeviceLogStore, pump: DeviceManager?, cgm: DeviceManager?) { + init( + appLog: any LogSink, + deviceLogStore: DeviceLogStore, + pump: DeviceManager?, + cgm: DeviceManager?, + storedValues: SensitiveValues = SensitiveValues() + ) { self.appLog = { appLog } managers = { (pump, cgm) } traceSubsystems = [:] includesAlgorithm = false + self.storedValues = storedValues self.deviceLogStore = deviceLogStore } @@ -122,7 +133,7 @@ final class LogExport: Injectable { let appLog = kind == .all ? self.appLog() : nil await appLog?.flush() let traceStore = traceLabels.isEmpty ? nil : try? OSLogStore(scope: .currentProcessIdentifier) - let redactor = Redactor(exactValues: await exactValues(in: window)) + let redactor = Redactor(exactValues: await sensitiveValues(in: window)) let algorithm = kind == .all && includesAlgorithm try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -148,15 +159,16 @@ final class LogExport: Injectable { 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: DeviceManager? - let cgm: DeviceManager? - if let managers { - (pump, cgm) = managers() - } else { - pump = deviceDataManager?.pumpManager - cgm = fetchGlucoseManager?.cgmGlucoseSourceType == .plugin ? fetchGlucoseManager?.cgmManager : nil - } + let (pump, cgm) = currentManagers() return (pump.map(Self.snapshot), cgm.map(Self.snapshot)) } @@ -231,33 +243,33 @@ final class LogExport: Injectable { return alerts.filter { window.contains($0.issuedDate) } } - private func exactValues(in window: DateInterval) async -> [Redactor.ExactValue] { - var values: [Redactor.ExactValue] = [] - let url: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey) - if let url, !url.isEmpty { - values.append(Redactor.ExactValue(value: url, kind: .url)) - if url.hasSuffix("/") { - values.append(Redactor.ExactValue(value: String(url.dropLast()), kind: .url)) - } - if let host = URL(string: url)?.host { - values.append(Redactor.ExactValue(value: host, kind: .host)) - } - } - let secret: String? = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey) - if let secret, !secret.isEmpty { - values.append(Redactor.ExactValue(value: secret, kind: .secret)) - } - var identifiers = Set() + private func sensitiveValues(in window: DateInterval) async -> [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) { for entry in await deviceLogStore.entries(in: slice) { - if let identifier = entry.deviceIdentifier, !identifier.isEmpty { - identifiers.insert(identifier) - } + values.add(entry.deviceIdentifier, .device) } } } - values += identifiers.sorted().map { Redactor.ExactValue(value: $0, kind: .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 } diff --git a/Trio/Sources/Logger/Export/RedactionPatterns.swift b/Trio/Sources/Logger/Export/RedactionPatterns.swift deleted file mode 100644 index a038ee8df..000000000 --- a/Trio/Sources/Logger/Export/RedactionPatterns.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -enum RedactionPatterns { - struct Pattern { - let regex: NSRegularExpression - let kind: Redactor.Kind - let group: Int - } - - // Order matters: Omnipod lot/TID digits run before the Dexcom TID pattern - static let all: [Pattern] = [ - make(#"https?://[^\s"'<>)]+"#, kind: .url), - make(#"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"#, kind: .email), - make(#"Bearer[ \t]+([A-Za-z0-9._-]+)"#, kind: .secret, group: 1), - make(#"(?i)\b(?:lot|tid)[: \t]+(\d+)\b"#, kind: .pumpSerial, group: 1), - make(#"(?i)\b(?:transmitter(?:ID)?|TID)[: \t"=]+([A-Za-z0-9]{6})\b"#, kind: .cgmSerial, group: 1), - make(labelled: ["lotNo", "lotSeq", "pumpSN"], kind: .pumpSerial), - make(labelled: ["sensorID", "sensorSerial", "transmitterSerial", "bleNameString"], kind: .cgmSerial), - make(labelled: ["deviceName", "serialNumber"], kind: .serial), - make(#"\b[0-9a-fA-F]{40}\b"#, kind: .secret) - ].compactMap { $0 } - - static let ignoredValues: Set = ["nil", "none", "unknown"] - - private static func make(_ pattern: String, kind: Redactor.Kind, group: Int = 0) -> Pattern? { - guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } - return Pattern(regex: regex, kind: kind, group: group) - } - - // Kit debug descriptions print serials as `label: value` or `label: Optional("value")` - private static func make(labelled labels: [String], kind: Redactor.Kind) -> Pattern? { - make( - #"(?i)\b(?:"# + labels.joined(separator: "|") + #")"?[ \t]*[:=][ \t]*(?:Optional\()?"?([^\s"),<][^\s"),]{2,})"#, - kind: kind, - group: 1 - ) - } -} diff --git a/Trio/Sources/Logger/Export/Redactor.swift b/Trio/Sources/Logger/Export/Redactor.swift index 2af88ecdd..732289bd3 100644 --- a/Trio/Sources/Logger/Export/Redactor.swift +++ b/Trio/Sources/Logger/Export/Redactor.swift @@ -2,14 +2,32 @@ import Foundation final class Redactor { enum Kind: String { - case url - case host + 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 device case email + case account case pumpSerial = "pump-serial" case cgmSerial = "cgm-serial" - case 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 { @@ -17,50 +35,134 @@ final class Redactor { let kind: Kind } - private static let minimumExactLength = 4 + 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 let exactValues: [ExactValue] - private let patterns: [RedactionPatterns.Pattern] + 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], patterns: [RedactionPatterns.Pattern] = RedactionPatterns.all) { - var seen = Set() - self.exactValues = exactValues - .map { ExactValue(value: $0.value.trimmingCharacters(in: .whitespacesAndNewlines), kind: $0.kind) } - .filter { $0.value.count >= Self.minimumExactLength && seen.insert($0.value.lowercased()).inserted } - .sorted { $0.value.count > $1.value.count } - self.patterns = patterns + 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 result = text - for exact in exactValues where result.range(of: exact.value, options: .caseInsensitive) != nil { - result = result.replacingOccurrences( - of: exact.value, - with: placeholder(for: exact.value, kind: exact.kind), - options: .caseInsensitive - ) + 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) + } } - for pattern in patterns { - result = apply(pattern, to: result) + var result = exact ?? text + for (rule, present) in zip(Self.inTransitRules, markers) where present { + result = apply(rule, to: result) } return result } - private func apply(_ pattern: RedactionPatterns.Pattern, to text: String) -> String { + 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 - let matches = pattern.regex.matches(in: text, range: NSRange(location: 0, length: source.length)) - guard !matches.isEmpty else { return text } var output = "" var cursor = 0 - for match in matches { - let range = match.range(at: pattern.group) - guard range.location != NSNotFound, range.location >= cursor else { continue } - let value = source.substring(with: range) - guard !RedactionPatterns.ignoredValues.contains(value.lowercased()) else { continue } + 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: value, kind: pattern.kind) + output += placeholder(for: source.substring(with: range), kind: rule.kind) cursor = range.location + range.length } output += source.substring(from: cursor) @@ -68,6 +170,7 @@ final class Redactor { } 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 @@ -76,4 +179,25 @@ final class Redactor { 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 000000000..60d33e760 --- /dev/null +++ b/Trio/Sources/Logger/Export/SensitiveValues.swift @@ -0,0 +1,203 @@ +import CGMBLEKit +import CryptoKit +import Foundation +import G7SensorKit +import LibreTransmitter +import LoopKit +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) + ], + "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 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/TrioTests/Logging/LogExportTests.swift b/TrioTests/Logging/LogExportTests.swift index 2fef6cde4..682ca931f 100644 --- a/TrioTests/Logging/LogExportTests.swift +++ b/TrioTests/Logging/LogExportTests.swift @@ -49,9 +49,17 @@ import Testing 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()) + export: LogExport( + appLog: appLog, + deviceLogStore: store, + pump: StubManager(), + cgm: StubCGMManager(), + storedValues: stored + ) ) } @@ -204,6 +212,6 @@ import Testing 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 ")) + #expect(all.contains("Trio [Service] upload to /api/v1/entries")) } } diff --git a/TrioTests/Logging/LoggingRobustnessStressTests.swift b/TrioTests/Logging/LoggingRobustnessStressTests.swift index 33f858ab5..eaee20a1c 100644 --- a/TrioTests/Logging/LoggingRobustnessStressTests.swift +++ b/TrioTests/Logging/LoggingRobustnessStressTests.swift @@ -1,4 +1,5 @@ import CoreData +import CryptoKit import Foundation import LoopKit import Testing @@ -1040,14 +1041,11 @@ private extension Duration { let secret = "synthetic-secret-0001" let host = "my-site.example.test" let device = "SYN-DEV-0042" - let hexToken = "0123456789abcdef0123456789abcdef01234567" - let redactor = Redactor(exactValues: [ - Redactor.ExactValue(value: "https://\(host)/", kind: .url), - Redactor.ExactValue(value: "https://\(host)", kind: .url), - Redactor.ExactValue(value: host, kind: .host), - Redactor.ExactValue(value: secret, kind: .secret), - Redactor.ExactValue(value: device, kind: .device) - ]) + 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", @@ -1090,8 +1088,8 @@ private extension Duration { for value in [secret, host, device, hexToken] { #expect(!text.contains(value.lowercased()), "\(value) leaked") } - #expect(text.hasPrefix(" header ")) - #expect(text.hasSuffix("last \n")) + #expect(text.hasPrefix(" header ")) + #expect(text.hasSuffix("last \n")) } @Test( @@ -1099,21 +1097,19 @@ private extension Duration { 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 scheme", "GET HTTPS://third-party.example.test/v1?token=abc123", "third-party.example.test"), - ("lowercase bearer", "authorization: bearer abc.DEF-123_xyz", "abc.def-123_xyz"), - ("uppercase bearer", "AUTHORIZATION: BEARER abc.DEF-123_xyz", "abc.def-123_xyz") + ("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) { - let redactor = Redactor(exactValues: [Redactor.ExactValue(value: "synthetic secret/0001!", kind: .secret)]) - let output = redactor.redact(input).lowercased() - withKnownIssue("Exact values match only their raw form; URL and Bearer patterns are case-sensitive") { - #expect(!output.contains(leaked), "\(output)") - } + 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 redactor = Redactor(exactValues: devices.map { Redactor.ExactValue(value: $0, kind: .device) }) let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZ") let serials = (0 ..< 5000).map { index -> String in var value = index @@ -1124,6 +1120,10 @@ private extension Duration { } 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])") } @@ -1162,34 +1162,26 @@ private extension Duration { "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) { - withKnownIssue("The Dexcom TID pattern accepts any six alphanumerics, including plain words") { - #expect(Redactor(exactValues: []).redact(input) == input) - } + 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: []) + let redactor = Redactor(exactValues: LoggingStressFixtures.storedValues.values) return (size, clock.measure { _ = redactor.redact(run) }.seconds) } - let probe = String(repeating: "a1", count: 8192) - let perPattern = RedactionPatterns.all.enumerated().map { index, pattern in - let elapsed = clock.measure { _ = Redactor(exactValues: [], patterns: [pattern]).redact(probe) } - return "#\(index) \(String(format: "%.3f", elapsed.seconds)) s" - } let spaced = String(repeating: "lorem ipsum ", count: 5 * 1_048_576 / 12) - let spacedTime = clock.measure { _ = Redactor(exactValues: []).redact(spaced) } + 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); 16 KB per pattern \(perPattern)" + "5 MB spaced text \(spacedTime)" ) - withKnownIssue("The email pattern rescans an unbroken run from every start position, so cost grows quadratically") { - #expect(ratio < 8) - } + #expect(ratio < 8) } // MARK: Export lifecycle diff --git a/TrioTests/Logging/LoggingStressFixtures.swift b/TrioTests/Logging/LoggingStressFixtures.swift index 05fff6155..0144a9763 100644 --- a/TrioTests/Logging/LoggingStressFixtures.swift +++ b/TrioTests/Logging/LoggingStressFixtures.swift @@ -61,6 +61,19 @@ enum LoggingStressFixtures { 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", diff --git a/TrioTests/Logging/LoggingVolumeStressTests.swift b/TrioTests/Logging/LoggingVolumeStressTests.swift index d36b4c9af..cd7d7e6cc 100644 --- a/TrioTests/Logging/LoggingVolumeStressTests.swift +++ b/TrioTests/Logging/LoggingVolumeStressTests.swift @@ -327,7 +327,13 @@ import Testing } private static func makeExport(_ appLog: AppLogStore, _ deviceLog: DeviceLogStore) -> LogExport { - LogExport(appLog: appLog, deviceLogStore: deviceLog, pump: StubManager(), cgm: StubCGMManager()) + LogExport( + appLog: appLog, + deviceLogStore: deviceLog, + pump: StubManager(), + cgm: StubCGMManager(), + storedValues: Fixtures.storedValues + ) } @Test("App log store ingests 90 days and purges to the cap, then to 7 days") func appLogIngest() async throws { @@ -531,20 +537,20 @@ import Testing day += 1 } - let redactor = Redactor(exactValues: [ - Redactor.ExactValue(value: Fixtures.siteURL, kind: .url), - Redactor.ExactValue(value: Fixtures.siteHost, kind: .host), - Redactor.ExactValue(value: Fixtures.pumpDevice, kind: .device), - Redactor.ExactValue(value: Fixtures.cgmDevice, kind: .device), - Redactor.ExactValue(value: Fixtures.retiredDevice, kind: .device) - ]) - let start = Self.clock.now + 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(Self.clock.now - start) + let seconds = Fixtures.seconds(elapsed) Fixtures.report("redactor", [ "input": Fixtures.megabytes(UInt64(total)), "chunks": "\(chunks.count)", @@ -552,50 +558,15 @@ import Testing "mbPerSec": String(format: "%.1f", Double(total) / Self.mb / seconds) ]) #expect(leaked == 0) - - // Per-stage cost on a 10 MB sample, with and without non-ASCII text - let sample = Array(chunks.prefix(160)) - let ascii = sample.map { String(String.UnicodeScalarView($0.unicodeScalars.filter(\.isASCII))) } - let sampleSize = Double(sample.reduce(0) { $0 + $1.utf8.count }) / Self.mb - func rate(_ texts: [String], _ redactor: Redactor) -> String { - let start = Self.clock.now - texts.forEach { _ = redactor.redact($0) } - return String(format: "%.1f", sampleSize / Fixtures.seconds(Self.clock.now - start)) - } - let exactValues = [Redactor.ExactValue(value: Fixtures.pumpDevice, kind: .device)] - var stages: [String] = [ - "exactOnly:\(rate(sample, Redactor(exactValues: exactValues, patterns: [])))", - "exactOnlyASCII:\(rate(ascii, Redactor(exactValues: exactValues, patterns: [])))", - "allPatternsASCII:\(rate(ascii, Redactor(exactValues: [])))" - ] - for (index, pattern) in RedactionPatterns.all.enumerated() { - stages.append("pattern\(index):\(rate(sample, Redactor(exactValues: [], patterns: [pattern])))") - } - // Anchoring the email local part at a token start avoids rescanning every word suffix - let anchoredEmail = - try NSRegularExpression(pattern: #"(?= 4) - } + #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" - withKnownIssue("Redactor's bare 40-hex secret pattern also matches 20-byte packet dumps") { - #expect(Redactor(exactValues: []).redact(packet) == packet) - } + // 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 { diff --git a/TrioTests/Logging/RedactorTests.swift b/TrioTests/Logging/RedactorTests.swift index 131cdd077..3c16fad47 100644 --- a/TrioTests/Logging/RedactorTests.swift +++ b/TrioTests/Logging/RedactorTests.swift @@ -1,70 +1,204 @@ +import CryptoKit import Foundation import Testing @testable import Trio @Suite("Redactor Tests") struct RedactorTests { - @Test( - "Patterns replace identifiers", - arguments: [ - ("upload to https://example-site.test/api/v1/entries failed", "upload to failed"), - ("account demo.user+cgm@example.org linked", "account linked"), - ("Authorization: Bearer abc.DEF-123_xyz", "Authorization: Bearer "), - ("token 0123456789abcdef0123456789abcdef01234567 sent", "token sent"), - ("Pod Lot: 123456789 TID: 7654321", "Pod Lot: TID: "), - ("lot 42424242 paired", "lot paired"), - ("transmitterID: 8G1ABC connected", "transmitterID: connected"), - ("Transmitter 8GXY12 found", "Transmitter found"), - ("{\"transmitterID\":\"8G9Z99\"}", "{\"transmitterID\":\"\"}"), - ("TID 8GAA11 battery ok", "TID battery ok"), - ("* lotNo: 135792468", "* lotNo: "), - ("* pumpSN: 0a1b2c3d", "* pumpSN: "), - ("sensorID: Optional(\"DX01AB\")", "sensorID: Optional(\"\")"), - ("* deviceName: Optional(\"meter+00042\")", "* deviceName: Optional(\"\")"), - ("bleNameString: nil", "bleNameString: nil") + 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: ") ] - ) func patterns(input: String, expected: String) { - #expect(Redactor(exactValues: []).redact(input) == expected) + 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=" + ), + ( + "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( - "Clinical values, timestamps, UUIDs and plugin identifiers stay untouched", + "Packet dumps, clinical values, timestamps, UUIDs and plugin identifiers stay untouched", arguments: [ - "BG 123 mg/dL", - "IOB 1.25", - "2026-10-18T12:00:00.000Z", + "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", - "DanaKit", - "com.randallknutson.DanaKit", - "* 2026-10-18T12:00:00.000Z DanaKit - send rate 0.85 U/h for 30 min", - "Transmitter battery low", - "Pod Lot:\n123456789 bolus", - "transmitterID:\nABC123 next line", - "deviceName:\nPump settings" + "com.randallknutson.DanaKit DexG6Transmitter G7CGMManager", + "Transmitter paired", + "TID search started" ] - ) func negatives(input: String) { - #expect(Redactor(exactValues: []).redact(input) == input) + ) 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: []) - let first = redactor.redact("a https://one.example.test b https://two.example.test") - let second = redactor.redact("c https://two.example.test d https://one.example.test") - #expect(first == "a b ") - #expect(second == "c d ") + 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("Exact values are replaced before patterns, longest first") func exactValuesFirst() { + @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: "my-site.example.test", kind: .host), - Redactor.ExactValue(value: "https://my-site.example.test/", kind: .url), - Redactor.ExactValue(value: "synthetic-secret-1", kind: .secret), - Redactor.ExactValue(value: "DANA-XYZ01", kind: .device), - Redactor.ExactValue(value: "abc", kind: .device) + 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 output = redactor.redact( - "GET https://my-site.example.test/api host my-site.example.test secret synthetic-secret-1 device dana-xyz01 abc" - ) - #expect(output == "GET api host secret device abc") + 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) } } From d581ac3151ac6ffa3413637d4917967beed5c409 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:45:59 +0200 Subject: [PATCH 23/48] Finish log export performance pass --- Trio/Sources/Logger/Core/TrioLogger.swift | 9 +- .../Logger/DeviceLog/DeviceLogStore.swift | 16 ++ Trio/Sources/Logger/Export/LogExport.swift | 32 ++- .../Sources/Logger/Export/LogFileWriter.swift | 1 + Trio/Sources/Logger/Store/AppLogStore.swift | 64 ++++-- TrioTests/Logging/AppLogStoreTests.swift | 38 ++++ TrioTests/Logging/DeviceLogStoreTests.swift | 22 ++ TrioTests/Logging/LogExportTests.swift | 53 +++++ .../LoggingRobustnessStressTests.swift | 13 +- TrioTests/Logging/LoggingStressFixtures.swift | 115 +++++++--- .../Logging/LoggingVolumeStressTests.swift | 209 ++++++++++++++---- TrioTests/Logging/TrioLoggerTests.swift | 11 + 12 files changed, 470 insertions(+), 113 deletions(-) diff --git a/Trio/Sources/Logger/Core/TrioLogger.swift b/Trio/Sources/Logger/Core/TrioLogger.swift index 266146cb6..2855b0e02 100644 --- a/Trio/Sources/Logger/Core/TrioLogger.swift +++ b/Trio/Sources/Logger/Core/TrioLogger.swift @@ -5,6 +5,7 @@ enum TrioLogger { private struct State: Sendable { var sink: any LogSink var installed: Bool + var lastTimestamp = Date.distantPast } private static let earlySink = InMemoryLogSink() @@ -34,8 +35,14 @@ enum TrioLogger { 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.sink) + } let record = LogRecord( - timestamp: Date(), + timestamp: timestamp, level: level, category: category.name, message: message, diff --git a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift index 4c47a05c8..534f69254 100644 --- a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift +++ b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift @@ -184,6 +184,7 @@ final class DeviceLogStore: @unchecked Sendable { } buffer.removeAll() deviceLog.purgeLogEntries(before: Date().addingTimeInterval(-maxAge)) + FileManager.default.removeUnreadableStores(of: storageURL) } private func moveStoreAside() { @@ -223,6 +224,21 @@ final class DeviceLogStore: @unchecked Sendable { } 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 diff --git a/Trio/Sources/Logger/Export/LogExport.swift b/Trio/Sources/Logger/Export/LogExport.swift index 551f07483..fe2fecdab 100644 --- a/Trio/Sources/Logger/Export/LogExport.swift +++ b/Trio/Sources/Logger/Export/LogExport.swift @@ -35,14 +35,14 @@ final class LogExport: Injectable { private let appLog: () -> any LogSink private let managers: (@MainActor() -> (pump: DeviceManager?, cgm: DeviceManager?))? private let traceSubsystems: [String: [String]] - private let includesAlgorithm: Bool + private let coreDataStack: CoreDataStack? private let storedValues: SensitiveValues init(resolver: Resolver) { appLog = { TrioLogger.sink } managers = nil traceSubsystems = OSLogCapture.subsystems - includesAlgorithm = true + coreDataStack = CoreDataStack.shared storedValues = SensitiveValues() injectServices(resolver) } @@ -52,12 +52,13 @@ final class LogExport: Injectable { deviceLogStore: DeviceLogStore, pump: DeviceManager?, cgm: DeviceManager?, - storedValues: SensitiveValues = SensitiveValues() + storedValues: SensitiveValues = SensitiveValues(), + coreDataStack: CoreDataStack? = nil ) { self.appLog = { appLog } managers = { (pump, cgm) } traceSubsystems = [:] - includesAlgorithm = false + self.coreDataStack = coreDataStack self.storedValues = storedValues self.deviceLogStore = deviceLogStore } @@ -65,9 +66,17 @@ final class LogExport: Injectable { 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 { @@ -133,8 +142,8 @@ final class LogExport: Injectable { let appLog = kind == .all ? self.appLog() : nil await appLog?.flush() let traceStore = traceLabels.isEmpty ? nil : try? OSLogStore(scope: .currentProcessIdentifier) - let redactor = Redactor(exactValues: await sensitiveValues(in: window)) - let algorithm = kind == .all && includesAlgorithm + 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") @@ -151,8 +160,8 @@ final class LogExport: Injectable { if let traceStore { lines += OSLogCapture.lines(from: traceStore, sources: traceLabels, in: slice) } - if algorithm { - lines += await Self.algorithmLines(in: slice) + if let algorithmStack { + lines += await Self.algorithmLines(in: slice, from: algorithmStack) } return lines } @@ -197,8 +206,8 @@ final class LogExport: Injectable { LogLine(date: entry.timestamp, source: source, text: "[\(entry.type.rawValue)] \(entry.message)") } - private static func algorithmLines(in window: DateInterval) async -> [LogLine] { - let context = CoreDataStack.shared.newTaskContext() + static func algorithmLines(in window: DateInterval, from stack: CoreDataStack) async -> [LogLine] { + let context = stack.newTaskContext() context.name = "logExportDeterminations" do { return try await context.perform { @@ -243,11 +252,12 @@ final class LogExport: Injectable { return alerts.filter { window.contains($0.issuedDate) } } - private func sensitiveValues(in window: DateInterval) async -> [Redactor.ExactValue] { + 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) } diff --git a/Trio/Sources/Logger/Export/LogFileWriter.swift b/Trio/Sources/Logger/Export/LogFileWriter.swift index 0005e8488..e4ff01bc2 100644 --- a/Trio/Sources/Logger/Export/LogFileWriter.swift +++ b/Trio/Sources/Logger/Export/LogFileWriter.swift @@ -51,6 +51,7 @@ enum LogFileWriter { } 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) } diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index 4656a21ac..86aa484fc 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -231,6 +231,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { } 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, @@ -238,26 +239,56 @@ final class AppLogStore: LogSink, @unchecked Sendable { minimumLevel.rawValue ) request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)] - return context.performAndWait { - let rows = (try? context.fetch(request)) ?? [] - return rows.compactMap { row in - guard let timestamp = row["timestamp"] as? Date, - let level = (row["level"] as? Int16).flatMap(LogLevel.init(rawValue:)) - else { return nil } - return 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() - ) + 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() @@ -350,6 +381,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { context.undoManager = nil self.context = context deleteLegacyFiles() + FileManager.default.removeUnreadableStores(of: storageURL) flushPending() } diff --git a/TrioTests/Logging/AppLogStoreTests.swift b/TrioTests/Logging/AppLogStoreTests.swift index f455ca46b..375931921 100644 --- a/TrioTests/Logging/AppLogStoreTests.swift +++ b/TrioTests/Logging/AppLogStoreTests.swift @@ -194,4 +194,42 @@ import UIKit #expect(await Self.waitUntil { loader.didLoad }) #expect(await store.records(in: Self.everything, minimumLevel: .debug) == records) } + + @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 index 2c23d2198..ad6627ce6 100644 --- a/TrioTests/Logging/DeviceLogStoreTests.swift +++ b/TrioTests/Logging/DeviceLogStoreTests.swift @@ -127,4 +127,26 @@ import UIKit #expect(preflight.callCount == 1) #expect(FileManager.default.fileExists(atPath: file.path)) } + + @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 index 682ca931f..2f5521a18 100644 --- a/TrioTests/Logging/LogExportTests.swift +++ b/TrioTests/Logging/LogExportTests.swift @@ -1,3 +1,4 @@ +import CoreData import Foundation import LoopKit import Testing @@ -214,4 +215,56 @@ import Testing #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/LoggingRobustnessStressTests.swift b/TrioTests/Logging/LoggingRobustnessStressTests.swift index eaee20a1c..86f8e57ff 100644 --- a/TrioTests/Logging/LoggingRobustnessStressTests.swift +++ b/TrioTests/Logging/LoggingRobustnessStressTests.swift @@ -429,9 +429,7 @@ private extension Duration { ) print("STRESS: per-producer inversions \(outOfOrder) \(inversions)") #expect(duplicates == 0) - withKnownIssue("Seen once in five runs; records are stamped with the wall clock", isIntermittent: true) { - #expect(outOfOrder == 0) - } + #expect(outOfOrder == 0) #expect(followUpSeen == producers * followUp) #expect(burstStored > 0) } @@ -984,9 +982,7 @@ private extension Duration { } let acceptedOrder = accepted.compactMap { $0.message.hasPrefix("duplicate ") ? Int($0.message.dropFirst(10)) : nil } #expect(duplicateOrder.sorted() == Array(0 ..< 200)) - withKnownIssue("Equal timestamps come back in SQLite's order, not the order they were logged", isIntermittent: true) { - #expect(duplicateOrder == acceptedOrder) - } + #expect(duplicateOrder == acceptedOrder) print("STRESS: edge export \(data.count) bytes, \(expectedRecords.count) lines") } @@ -1248,14 +1244,17 @@ private extension Duration { 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))") + 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"), diff --git a/TrioTests/Logging/LoggingStressFixtures.swift b/TrioTests/Logging/LoggingStressFixtures.swift index 0144a9763..a2808d4a1 100644 --- a/TrioTests/Logging/LoggingStressFixtures.swift +++ b/TrioTests/Logging/LoggingStressFixtures.swift @@ -2,6 +2,7 @@ import CoreData import Darwin import Foundation import LoopKit +import Testing @testable import Trio @@ -28,18 +29,46 @@ struct StressRandom: RandomNumberGenerator { } } +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 linesPerCycle = 60 - static let burstsPerDay = 4 - static let burstSize = 1000 - static let pumpPairsPerCycle = 34 - static let cgmPerCycle = 2 static let retiredDays = 30 - static let forecastLength = 12 static let siteURL = "https://stress-site.example.test/api" static let siteHost = "stress-site.example.test" @@ -92,13 +121,13 @@ enum LoggingStressFixtures { end.addingTimeInterval(-Double(days - index) * day) } - static func appRecords(day index: Int, end: Date) -> [LogRecord] { + 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 ..< burstsPerDay).map { _ in Int.random(in: 0 ..< cyclesPerDay, using: &rng) }) + let burstCycles = Set((0 ..< profile.burstsPerDay).map { _ in Int.random(in: 0 ..< cyclesPerDay, using: &rng) }) var records: [LogRecord] = [] - records.reserveCapacity(cyclesPerDay * linesPerCycle + burstsPerDay * burstSize) + records.reserveCapacity(cyclesPerDay * profile.linesPerCycle + profile.burstsPerDay * profile.burstSize) func append(at date: Date, burst: Bool) { let level: LogLevel @@ -112,7 +141,7 @@ enum LoggingStressFixtures { timestamp: date, level: level, category: categories.randomElement(using: &rng) ?? "Loop", - message: "#a\(index).\(records.count) " + message(using: &rng, burst: burst), + 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), @@ -122,12 +151,12 @@ enum LoggingStressFixtures { for cycleIndex in 0 ..< cyclesPerDay { let cycleStart = start.addingTimeInterval(Double(cycleIndex) * cycle) - for line in 0 ..< linesPerCycle { + 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 ..< burstSize { + for line in 0 ..< profile.burstSize { append(at: burstStart.addingTimeInterval(Double(line) * 0.001), burst: true) } } @@ -149,7 +178,7 @@ enum LoggingStressFixtures { } } - private static func message(using rng: inout StressRandom, burst: Bool) -> String { + 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)) } @@ -161,7 +190,7 @@ enum LoggingStressFixtures { .map { _ in sentence(words: Int.random(in: 4 ... 16, using: &rng), using: &rng) } .joined(separator: "\n") } - return sentence(words: Int.random(in: 5 ... 30, using: &rng), using: &rng) + return sentence(words: Int.random(in: words, using: &rng), using: &rng) } private static func sentence(words count: Int, using rng: inout StressRandom) -> String { @@ -178,7 +207,7 @@ enum LoggingStressFixtures { return text } - static func deviceEntries(day index: Int, end: Date) -> [StoredDeviceLogEntry] { + 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] = [] @@ -199,13 +228,19 @@ enum LoggingStressFixtures { for cycleIndex in 0 ..< cyclesPerDay { let cycleStart = start.addingTimeInterval(Double(cycleIndex) * cycle) - for pair in 0 ..< pumpPairsPerCycle { + 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)) - 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)) + 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 { @@ -217,7 +252,7 @@ enum LoggingStressFixtures { }() pump(event.0, event.1, at: cycleStart.addingTimeInterval(30)) - for reading in 0 ..< cgmPerCycle { + 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" @@ -288,37 +323,59 @@ enum LoggingStressFixtures { 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: Int.random(in: 60 ... 250, using: &rng)) - determination.iob = NSDecimalNumber(value: Double(Int.random(in: -50 ... 600, using: &rng)) / 100) - determination.cob = Int16.random(in: 0 ... 80, using: &rng) - determination.rate = NSDecimalNumber(value: Double(Int.random(in: 0 ... 300, using: &rng)) / 100) + determination.glucose = NSDecimalNumber(value: glucose) + determination.iob = NSDecimalNumber(value: iob) + determination.cob = cob + determination.rate = NSDecimalNumber(value: rate) determination.duration = 30 - determination.eventualBG = NSDecimalNumber(value: Int.random(in: 60 ... 250, using: &rng)) - determination.reason = "COB: 0, Dev: 4, BGI: -1, ISF: 54, CR: 9, Target: 100, minPredBG 98, IOBpredBG 101" - for type in ["iob", "cob", "uam", "zt"] { + 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.random(in: 80 ... 180, using: &rng) - for position in 0 ..< forecastLength { + 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 += Int32.random(in: -6 ... 6, using: &rng) + value = max(39, value + Int32.random(in: -6 ... 6, using: &rng)) } } } diff --git a/TrioTests/Logging/LoggingVolumeStressTests.swift b/TrioTests/Logging/LoggingVolumeStressTests.swift index cd7d7e6cc..329bfd525 100644 --- a/TrioTests/Logging/LoggingVolumeStressTests.swift +++ b/TrioTests/Logging/LoggingVolumeStressTests.swift @@ -38,13 +38,28 @@ import Testing } private actor Stores { - static let shared = Stores() + static let shared = Stores(profile: .heavy) + static let typical = Stores(profile: .typical) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent("LoggingVolumeStress", isDirectory: true) + 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") } @@ -65,9 +80,22 @@ import Testing 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) } } @@ -117,6 +145,7 @@ import Testing 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, @@ -131,7 +160,7 @@ import Testing var newest = Date.distantPast var elapsed = Duration.zero for day in 0 ..< Fixtures.days { - let records = Fixtures.appRecords(day: day, end: end) + 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) @@ -159,7 +188,7 @@ import Testing let ingested = appRows(url) let ingestedSize = Fixtures.storeSize(url) - Fixtures.report("appLog.ingest", [ + Fixtures.report("appLog.ingest.\(profile.name)", [ "records": "\(accepted)", "perDay": "\(accepted / Fixtures.days)", "rowsPerSec": String(format: "%.0f", Double(accepted) / Fixtures.seconds(elapsed)), @@ -178,7 +207,7 @@ import Testing 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", [ + Fixtures.report("appLog.rowCap.\(profile.name)", [ "maxRows": "\(capped.maxRows)", "rows": "\(afterCap.count)", "daysCovered": String(format: "%.2f", coveredDays), @@ -195,7 +224,7 @@ import Testing await store.flush() let ageDuration = Self.clock.now - ageStart let afterAge = appRows(url) - Fixtures.report("appLog.agePurge", [ + 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)), @@ -210,6 +239,7 @@ import Testing 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() @@ -219,7 +249,7 @@ import Testing var inserted = 0 var elapsed = Duration.zero for day in 0 ..< Fixtures.days { - let entries = Fixtures.deviceEntries(day: day, end: end) + 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 @@ -240,7 +270,7 @@ import Testing let ingestPeak = sampler.stop() let ingestedSize = Fixtures.storeSize(url) #expect(deviceRows(url).count == inserted) - Fixtures.report("deviceLog.ingest", [ + Fixtures.report("deviceLog.ingest.\(profile.name)", [ "entries": "\(inserted)", "perDay": "\(inserted / Fixtures.days)", "rowsPerSec": String(format: "%.0f", Double(inserted) / Fixtures.seconds(elapsed)), @@ -256,7 +286,7 @@ import Testing let purgeDuration = Self.clock.now - purgeStart let purgePeak = purgeSampler.stop() let remaining = deviceRows(url) - Fixtures.report("deviceLog.openAndPurge", [ + 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)), @@ -326,23 +356,34 @@ import Testing return archive } - private static func makeExport(_ appLog: AppLogStore, _ deviceLog: DeviceLogStore) -> LogExport { + private static func makeExport( + _ appLog: AppLogStore, + _ deviceLog: DeviceLogStore, + algorithm: CoreDataStack? = nil + ) -> LogExport { LogExport( appLog: appLog, deviceLogStore: deviceLog, pump: StubManager(), cgm: StubCGMManager(), - storedValues: Fixtures.storedValues + storedValues: Fixtures.storedValues, + coreDataStack: algorithm ) } - @Test("App log store ingests 90 days and purges to the cap, then to 7 days") func appLogIngest() async throws { - await Stores.shared.reset() - _ = try await Stores.shared.preparedAppLog() + @Test( + "App log store ingests 90 days and purges to the cap, then to 7 days", + 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 7 days on open") func deviceLogIngest() async throws { - _ = try await Stores.shared.preparedDeviceLog() + @Test( + "Device log store ingests 90 days and purges to 7 days 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 { @@ -381,12 +422,17 @@ import Testing #expect(await store.entries(in: DateInterval(start: Date().addingTimeInterval(-600), end: Date())).count == count) } - @Test("Exports of every kind and length are sorted, complete and redacted") func exports() async throws { - let appLog = try await Stores.shared.preparedAppLog() - let deviceLog = try await Stores.shared.preparedDeviceLog() - let appURL = await Stores.shared.appLogURL - let deviceURL = await Stores.shared.deviceLogURL - let export = Self.makeExport(appLog, deviceLog) + @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] { @@ -397,6 +443,22 @@ import Testing 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")) @@ -409,7 +471,7 @@ import Testing let textSize = Fixtures.fileSize(url) let zipSize = Fixtures.fileSize(archive) let analysis = Self.analyze(try String(contentsOf: url, encoding: .utf8)) - let name = "\(kind)-\(days)d" + let name = "\(profile.name).\(kind)-\(days)d" Fixtures.report("export.\(name)", [ "seconds": String(format: "%.2f", Fixtures.seconds(duration)), @@ -430,31 +492,20 @@ import Testing #expect(analysis.markers.values.allSatisfy { $0 == 1 }, "\(name) markers \(analysis.markers)") #expect(analysis.counts[Fixtures.retiredManager] == nil, "\(name)") - func expectCount(_ source: String, rows: (Date) -> Int) { - let upper = rows(before.addingTimeInterval(-span)) - let lower = rows(after.addingTimeInterval(-span)) + #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 - #expect(found >= lower && found <= upper, "\(name) \(source): \(found) not in \(lower)...\(upper)") + let upperBound = upper[source] ?? 0 + #expect(found >= lower && found <= upperBound, "\(name) \(source): \(found) not in \(lower)...\(upperBound)") } - let expected: Set - switch kind { - case .all: expected = ["Trio", "Pump", "CGM"] - case .pump: expected = ["Pump"] - case .cgm: expected = ["CGM"] - } - #expect(Set(analysis.counts.keys) == expected, "\(name)") - if expected.contains("Trio") { - expectCount("Trio") { Self.appRows(appURL, Self.since($0)).count } - } - if expected.contains("Pump") { - expectCount("Pump") { Self.deviceRows(deviceURL, Self.since($0, manager: Fixtures.pumpManager)).count } - } - if expected.contains("CGM") { - expectCount("CGM") { Self.deviceRows(deviceURL, Self.since($0, manager: Fixtures.cgmManager)).count } - } - if kind == .all, days == 7 { - #expect(Fixtures.seconds(duration) < 60) + let limit: Double = switch (kind, days) { + case (.all, 1): 1 + case (.all, _): 5 + default: 2 } + #expect(Fixtures.seconds(duration) <= limit, "\(name) took \(duration)") } } @@ -464,18 +515,77 @@ import Testing for slice in LogFileWriter.slices(of: window) { identifierEntries += await deviceLog.entries(in: slice).count } - Fixtures.report("export.identifierPass7d", [ + 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.makeFile-all-7d", [ - "seconds": String(format: "%.2f", Fixtures.seconds(Self.clock.now - start)), + 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 { @@ -663,7 +773,7 @@ import Testing let total = try await context.perform { try context.count(for: OrefDetermination.fetchRequest()) } Fixtures.report("determinations", [ "rows": "\(total)", - "forecastValuesPerRow": "\(4 * Fixtures.forecastLength)", + "forecastValuesPerRow": "≤192", "insertSeconds": String(format: "%.1f", Fixtures.seconds(insertDuration)), "peakFootprint": Fixtures.megabytes(peak.footprint), "fetch7dSeconds": String(format: "%.3f", Fixtures.seconds(fetchDuration)), @@ -675,5 +785,6 @@ import Testing @Test("Clean up stress stores") func cleanUp() async { await Stores.shared.reset() + await Stores.typical.reset() } } diff --git a/TrioTests/Logging/TrioLoggerTests.swift b/TrioTests/Logging/TrioLoggerTests.swift index eab75d5fe..7164ba51d 100644 --- a/TrioTests/Logging/TrioLoggerTests.swift +++ b/TrioTests/Logging/TrioLoggerTests.swift @@ -78,4 +78,15 @@ import Testing } #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 }) + } } From b86d399ae498d2546a47e16fd30d17169e584a2f Mon Sep 17 00:00:00 2001 From: Lamercho <33007090+Lamercho@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:01:58 +0200 Subject: [PATCH 24/48] Re-run CI on current dev From 5628b0f4be9fedf75b339dc6383eb8bac66613da Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:44:34 +0200 Subject: [PATCH 25/48] Add Report a Problem --- Trio.xcodeproj/project.pbxproj | 4 + .../Localizations/Main/Localizable.xcstrings | 408 +----------------- .../Settings/View/SettingsRootView.swift | 80 +--- .../View/Subviews/ReportProblemView.swift | 83 ++++ Trio/Sources/Views/GlassActionSheet.swift | 15 +- Trio/Sources/Views/LogShareRow.swift | 39 +- 6 files changed, 142 insertions(+), 487 deletions(-) create mode 100644 Trio/Sources/Modules/Settings/View/Subviews/ReportProblemView.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 7522aafea..bc628ba2a 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ 4E3B6212E43A3E90DD735B30 /* AppLog.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */; }; 536B4DB89FCFE9188F4DFE5D /* SensitiveValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCB6F0602E069F5B4F83EDC /* SensitiveValues.swift */; }; 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */; }; + 659C83549641F3FCC56F30AB /* ReportProblemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7373EC0CC3C059178CB967B4 /* ReportProblemView.swift */; }; 7BA97B74F25D6A1F5D853EFB /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F602845F4994CA0A57286EE6 /* ShareSheet.swift */; }; 91AE5F35FC765DFE4C1A2589 /* AppLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */; }; 91FC2FB975FB723C55744FB1 /* LogSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB5F206A024F911C4E145D5 /* LogSink.swift */; }; @@ -1619,6 +1620,7 @@ 6B1A8D2D2B156EEF00E76752 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; 6BCF84DC2B16843A003AD46E /* LiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityAttributes.swift; sourceTree = ""; }; 704720C410E0C4F7D10B184B /* RedactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactorTests.swift; sourceTree = ""; }; + 7373EC0CC3C059178CB967B4 /* ReportProblemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProblemView.swift; sourceTree = ""; }; 7DB8B37A782E6D75420DB2FC /* LogExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExport.swift; sourceTree = ""; }; 7E2CF3BABA4BF1C48CC96FD7 /* LiveActivityGlucoseStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityGlucoseStyle.swift; sourceTree = ""; }; 7FBFFB8EF42D847AFFDA6C05 /* LogFileHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFileHeader.swift; sourceTree = ""; }; @@ -4595,6 +4597,7 @@ DD1745182C543B5700211FAC /* NotificationsView.swift */, DD17451C2C543C5F00211FAC /* ServicesView.swift */, DD1745362C55B74200211FAC /* AlgorithmSettings.swift */, + 7373EC0CC3C059178CB967B4 /* ReportProblemView.swift */, ); path = Subviews; sourceTree = ""; @@ -6416,6 +6419,7 @@ 96310848E7A3468607C340E9 /* LogExport.swift in Sources */, F6854A961CB1CF1105757936 /* LogShareRow.swift in Sources */, 536B4DB89FCFE9188F4DFE5D /* SensitiveValues.swift in Sources */, + 659C83549641F3FCC56F30AB /* ReportProblemView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 4ef934c79..d253013b4 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1094,6 +1094,12 @@ }, "Also keep detailed debug messages in the app log." : { + }, + "Ask on Discord" : { + + }, + "Ask on Facebook" : { + }, "How long Trio keeps its app log on this device." : { @@ -1103,6 +1109,18 @@ }, "Logging" : { + }, + "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." : { + }, "Verbose Logging" : { @@ -285249,136 +285267,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" : { @@ -320587,136 +320475,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" : { @@ -321107,136 +320865,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" : { diff --git a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift index d5189d0ba..64e38d508 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 showLogErrorToast = false @State private var searchText: String = "" @State var hintDetent = PresentationDetent.large @State private var hintPayload: HintPayload? @@ -129,14 +128,6 @@ extension Settings { } } - private func showLogError() { - Task { @MainActor in - withAnimation { showLogErrorToast = true } - try? await Task.sleep(nanoseconds: 2_000_000_000) - withAnimation { showLogErrorToast = false } - } - } - private func copyVersionInfo(_ text: String) { UIPasteboard.general.string = text UINotificationFeedbackGenerator().notificationOccurred(.success) @@ -289,63 +280,14 @@ extension Settings { .listRowBackground(Color.chart) Section( - header: Text("Support & Community"), content: { - LogShareRow( - title: "Share Trio Logs", - kinds: state.logKinds, - logExport: state.logExport, - onFailure: showLogError - ) - - 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) - } + NavigationLink(destination: ReportProblemView(state: state)) { + Text("Report a Problem") } - .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) - } - } - .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 { @@ -441,16 +383,6 @@ extension Settings { .padding(.bottom, 32) .transition(.move(edge: .bottom).combined(with: .opacity)) } - if showLogErrorToast { - Label("Could not prepare logs", systemImage: "exclamationmark.triangle.fill") - .font(.footnote.weight(.semibold)) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(.ultraThinMaterial, in: Capsule()) - .padding(.bottom, 32) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } } .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme)) .sheet(item: $hintPayload) { payload in 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 000000000..b28806d58 --- /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/Views/GlassActionSheet.swift b/Trio/Sources/Views/GlassActionSheet.swift index 6fe221a3d..8bfe3fca5 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 index e020cd470..1664d8f82 100644 --- a/Trio/Sources/Views/LogShareRow.swift +++ b/Trio/Sources/Views/LogShareRow.swift @@ -5,14 +5,13 @@ struct LogShareRow: View { var footnote: LocalizedStringKey? let kinds: [LogExport.Kind] let logExport: LogExport? - var onFailure: (() -> Void)? @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 showShareSheet = false + @State private var sharedFile: SharedFile? @State private var showError = false var body: some View { @@ -44,20 +43,19 @@ struct LogShareRow: View { .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 } } ) - .onChange(of: showKinds) { _, shown in - // The sheet runs the chosen action before it clears its binding - if !shown, pendingKind != nil { - showDays = true - } - } .glassActionSheet( "How far back?", isPresented: $showDays, @@ -68,8 +66,11 @@ struct LogShareRow: View { ], onCancel: { pendingKind = nil } ) - .sheet(isPresented: $showShareSheet, onDismiss: discardFile) { - ShareSheet(activityItems: file.map { [$0] } ?? []) + // 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) {} @@ -93,22 +94,19 @@ struct LogShareRow: View { isPreparing = true Task { @MainActor in do { - file = try await logExport.makeFile(kind, days: days) - isPreparing = false - showShareSheet = true + 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) - if let onFailure { - onFailure() - } else { - showError = true - } + showError = true } } } private func discardFile() { + isPreparing = false guard let file else { return } self.file = nil Task.detached(priority: .utility) { @@ -117,6 +115,11 @@ struct LogShareRow: View { } } +private struct SharedFile: Identifiable { + let url: URL + var id: URL { url } +} + private extension LogExport.Kind { var label: LocalizedStringKey { switch self { From 8426682ccdecfea0e800255e41de680b5b4da688 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:16:04 +0200 Subject: [PATCH 26/48] Retry store open after a missed unlock --- .../Logger/DeviceLog/DeviceLogStore.swift | 12 +++++++++++- Trio/Sources/Logger/Store/AppLogStore.swift | 12 +++++++++++- TrioTests/Logging/AppLogStoreTests.swift | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift index 534f69254..361a96e50 100644 --- a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift +++ b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift @@ -32,6 +32,7 @@ final class DeviceLogStore: @unchecked Sendable { private var deviceLog: PersistentDeviceLog? private var buffer: [PendingEntry] = [] private var isCheckingProtectedData = false + private var retryAfterCheck = false private var didMoveStoreAside = false init( @@ -135,7 +136,12 @@ final class DeviceLogStore: @unchecked Sendable { } private func openIfNeeded() { - guard deviceLog == nil, !isCheckingProtectedData else { return } + 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 @@ -151,6 +157,10 @@ final class DeviceLogStore: @unchecked Sendable { self.queue.async { self.isCheckingProtectedData = false self.recover(protectedDataAvailable: available) + if self.retryAfterCheck { + self.retryAfterCheck = false + self.openIfNeeded() + } } } } diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index 86aa484fc..7850405ce 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -39,6 +39,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { private var policy: LogRetentionPolicy private var lastPurge: Date? private var isCheckingProtectedData = false + private var retryAfterCheck = false private var didMoveStoreAside = false private var didReopenAfterFailure = false @@ -320,7 +321,12 @@ final class AppLogStore: LogSink, @unchecked Sendable { } private func openIfNeeded() { - guard container == nil, !isCheckingProtectedData else { return } + 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) @@ -333,6 +339,10 @@ final class AppLogStore: LogSink, @unchecked Sendable { self.queue.async { self.isCheckingProtectedData = false self.recover(protectedDataAvailable: available) + if self.retryAfterCheck { + self.retryAfterCheck = false + self.openIfNeeded() + } } } } diff --git a/TrioTests/Logging/AppLogStoreTests.swift b/TrioTests/Logging/AppLogStoreTests.swift index 375931921..f384f4fd3 100644 --- a/TrioTests/Logging/AppLogStoreTests.swift +++ b/TrioTests/Logging/AppLogStoreTests.swift @@ -195,6 +195,23 @@ import UIKit #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) } From 6df8c0c75bc3c9de22b279efa53ce51edfcb1807 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:58:19 +0200 Subject: [PATCH 27/48] Add shared watch log entry --- Trio.xcodeproj/project.pbxproj | 14 +++++ Trio/Sources/Logger/Watch/WatchLogEntry.swift | 63 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 Trio/Sources/Logger/Watch/WatchLogEntry.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index bc628ba2a..a61b7ca89 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -913,6 +913,7 @@ DD30BA1A2E08AB9F00DA677C /* CarbImpactParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD30BA192E08AB9F00DA677C /* CarbImpactParams.swift */; }; DD32CF982CC82463003686D6 /* TrioRemoteControl+Bolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF972CC82460003686D6 /* TrioRemoteControl+Bolus.swift */; }; DD32CF9A2CC8247B003686D6 /* TrioRemoteControl+Meal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CF992CC8246F003686D6 /* TrioRemoteControl+Meal.swift */; }; + E8C6B60FFBA9D89DE3BCCA99 /* WatchLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D79872B74ADDBDD11682D0 /* WatchLogEntry.swift */; }; EAE1104EF2313F9A0041C746 /* LoggingVolumeStressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A4F4087B27F7D5819A37272 /* LoggingVolumeStressTests.swift */; }; F07C663CC423264E609D706C /* LoggingRobustnessStressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258BD00B3EAADF1A62EFC884 /* LoggingRobustnessStressTests.swift */; }; F0D6F2237EB1FB4862D0D4D7 /* AppLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39062D177D83C69CAFC9B2AC /* AppLogStore.swift */; }; @@ -1115,6 +1116,7 @@ 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 */; }; + FF42480CF46A31D00C817D8C /* WatchLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D79872B74ADDBDD11682D0 /* WatchLogEntry.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -2299,6 +2301,7 @@ E592A3742CEEC038009A472C /* ContactImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageProvider.swift; sourceTree = ""; }; E592A3752CEEC038009A472C /* ContactImageStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageStateModel.swift; sourceTree = ""; }; E9C171A8FC35B730F1FFF25F /* InMemoryLogSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryLogSink.swift; sourceTree = ""; }; + F5D79872B74ADDBDD11682D0 /* WatchLogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogEntry.swift; sourceTree = ""; }; F602845F4994CA0A57286EE6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; F7EB8FEFF04CF8327EE83D84 /* LogShareRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogShareRow.swift; sourceTree = ""; }; F816825D28DB441200054060 /* HeartBeatManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartBeatManager.swift; sourceTree = ""; }; @@ -3477,6 +3480,7 @@ B57248ACB19D6554EBCAA15E /* DeviceLog */, 9EFA1E4C7EB7203F0D39B585 /* Export */, 5BA8CACAF5028913393A78CB /* Store */, + AB59F766D97F5B125B0BEFDD /* Watch */, ); path = Logger; sourceTree = ""; @@ -3949,6 +3953,14 @@ path = Logging; sourceTree = ""; }; + AB59F766D97F5B125B0BEFDD /* Watch */ = { + isa = PBXGroup; + children = ( + F5D79872B74ADDBDD11682D0 /* WatchLogEntry.swift */, + ); + path = Watch; + sourceTree = ""; + }; B015AFE22E500000000D7351 /* BolusSafety */ = { isa = PBXGroup; children = ( @@ -6420,6 +6432,7 @@ F6854A961CB1CF1105757936 /* LogShareRow.swift in Sources */, 536B4DB89FCFE9188F4DFE5D /* SensitiveValues.swift in Sources */, 659C83549641F3FCC56F30AB /* ReportProblemView.swift in Sources */, + E8C6B60FFBA9D89DE3BCCA99 /* WatchLogEntry.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6593,6 +6606,7 @@ BD04ECCE2D29952A008C5FEB /* BolusProgressOverlay.swift in Sources */, DD09D5C92D29F3D0000D82C9 /* AcknowledgementPendingView.swift in Sources */, DDFF204A2DB29EF500AB8A96 /* WatchLogger.swift in Sources */, + FF42480CF46A31D00C817D8C /* WatchLogEntry.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Trio/Sources/Logger/Watch/WatchLogEntry.swift b/Trio/Sources/Logger/Watch/WatchLogEntry.swift new file mode 100644 index 000000000..eb6b1fb6a --- /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 +} From c7902e41b960ed3f9704ed5fe8f4134cdfdf5fc2 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:58:19 +0200 Subject: [PATCH 28/48] Keep watch logs in a persistent ring --- Trio.xcodeproj/project.pbxproj | 6 + Trio/Sources/Logger/Watch/WatchLogRing.swift | 203 +++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 Trio/Sources/Logger/Watch/WatchLogRing.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index a61b7ca89..889e65c9f 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -153,8 +153,10 @@ 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */; }; 659C83549641F3FCC56F30AB /* ReportProblemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7373EC0CC3C059178CB967B4 /* ReportProblemView.swift */; }; 7BA97B74F25D6A1F5D853EFB /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F602845F4994CA0A57286EE6 /* ShareSheet.swift */; }; + 8B735B34F861E3AE08F5C9BE /* WatchLogRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75C18518C0B49FB16C05F355 /* WatchLogRing.swift */; }; 91AE5F35FC765DFE4C1A2589 /* AppLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */; }; 91FC2FB975FB723C55744FB1 /* LogSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB5F206A024F911C4E145D5 /* LogSink.swift */; }; + 92DE66B4776804F0DD8A2925 /* WatchLogRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75C18518C0B49FB16C05F355 /* WatchLogRing.swift */; }; 931AD2C542093B9D1A342065 /* InMemoryLogSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C171A8FC35B730F1FFF25F /* InMemoryLogSink.swift */; }; 96310848E7A3468607C340E9 /* LogExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DB8B37A782E6D75420DB2FC /* LogExport.swift */; }; A8354E8A31A4F482D0B649F1 /* Redactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0396B90CF23D28D2FDCD78 /* Redactor.swift */; }; @@ -1623,6 +1625,7 @@ 6BCF84DC2B16843A003AD46E /* LiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityAttributes.swift; sourceTree = ""; }; 704720C410E0C4F7D10B184B /* RedactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactorTests.swift; sourceTree = ""; }; 7373EC0CC3C059178CB967B4 /* ReportProblemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportProblemView.swift; sourceTree = ""; }; + 75C18518C0B49FB16C05F355 /* WatchLogRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogRing.swift; sourceTree = ""; }; 7DB8B37A782E6D75420DB2FC /* LogExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExport.swift; sourceTree = ""; }; 7E2CF3BABA4BF1C48CC96FD7 /* LiveActivityGlucoseStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityGlucoseStyle.swift; sourceTree = ""; }; 7FBFFB8EF42D847AFFDA6C05 /* LogFileHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFileHeader.swift; sourceTree = ""; }; @@ -3957,6 +3960,7 @@ isa = PBXGroup; children = ( F5D79872B74ADDBDD11682D0 /* WatchLogEntry.swift */, + 75C18518C0B49FB16C05F355 /* WatchLogRing.swift */, ); path = Watch; sourceTree = ""; @@ -6433,6 +6437,7 @@ 536B4DB89FCFE9188F4DFE5D /* SensitiveValues.swift in Sources */, 659C83549641F3FCC56F30AB /* ReportProblemView.swift in Sources */, E8C6B60FFBA9D89DE3BCCA99 /* WatchLogEntry.swift in Sources */, + 92DE66B4776804F0DD8A2925 /* WatchLogRing.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6607,6 +6612,7 @@ DD09D5C92D29F3D0000D82C9 /* AcknowledgementPendingView.swift in Sources */, DDFF204A2DB29EF500AB8A96 /* WatchLogger.swift in Sources */, FF42480CF46A31D00C817D8C /* WatchLogEntry.swift in Sources */, + 8B735B34F861E3AE08F5C9BE /* WatchLogRing.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Trio/Sources/Logger/Watch/WatchLogRing.swift b/Trio/Sources/Logger/Watch/WatchLogRing.swift new file mode 100644 index 000000000..49baacae4 --- /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 } + } +} From 34e28385ae044ae47a5141ab743bd045baed91c6 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:58:19 +0200 Subject: [PATCH 29/48] Send watch logs as queued files --- Trio Watch App Extension/TrioWatchApp.swift | 4 +- Trio Watch App Extension/WatchLogger.swift | 301 +++++++++++++------- Trio Watch App Extension/WatchState.swift | 7 + 3 files changed, 203 insertions(+), 109 deletions(-) diff --git a/Trio Watch App Extension/TrioWatchApp.swift b/Trio Watch App Extension/TrioWatchApp.swift index fe8e7f96c..af2e17b11 100644 --- a/Trio Watch App Extension/TrioWatchApp.swift +++ b/Trio Watch App Extension/TrioWatchApp.swift @@ -14,9 +14,7 @@ import UserNotifications } .onChange(of: scenePhase) { _, newScenePhase in if newScenePhase == .background { - Task { - await WatchLogger.shared.flushPersistedLogs() - } + WatchLogger.shared.didEnterBackground() } } } diff --git a/Trio Watch App Extension/WatchLogger.swift b/Trio Watch App Extension/WatchLogger.swift index 666e26271..69b51085b 100644 --- a/Trio Watch App Extension/WatchLogger.swift +++ b/Trio Watch App Extension/WatchLogger.swift @@ -1,151 +1,240 @@ 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 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 session = WCSession.default - private var timerTask: Task? + private let pending = OSAllocatedUnfairLock(initialState: PendingDrafts()) + private let shipper = WatchLogShipper() + 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)) + } + + func error(_ category: WatchLogEntry.Category, _ message: String, error: Error? = nil) { + log(.error, category, Self.message(message, error: error)) + } - print(entry) - await flushIfNeeded(force: force) + func log(_ message: String, force _: Bool = false) async { + debug(.data, message) } - func flushIfNeeded(force: Bool = false) async { - let now = Date() - let shouldFlush = force || now.timeIntervalSince(lastFlush) >= flushInterval || logs.count >= flushSizeThreshold + func persistLogsLocally() async {} - if shouldFlush { - await flushToPhone() + func didEnterBackground() { + Task { [shipper, pending] in + await shipper.drain(pending) + await shipper.ship(onRequest: false) } } - private func flushToPhone() async { - guard !logs.isEmpty else { - return + // 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 + } - let batch = logs - let payload: [String: Any] = ["watchLogs": batch.joined(separator: "\n")] - - 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(batch) - } + 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(_ entries: [String]? = nil) 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 = (entries ?? 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(logString.split(whereSeparator: \.isNewline).map(String.init)) - } - } - 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.swift b/Trio Watch App Extension/WatchState.swift index 805f52895..e8bfa9830 100644 --- a/Trio Watch App Extension/WatchState.swift +++ b/Trio Watch App Extension/WatchState.swift @@ -189,6 +189,8 @@ import WatchConnectivity /// Handles incoming messages from the paired iPhone when Phone is in the foreground func session(_: WCSession, didReceiveMessage message: [String: Any]) { + if WatchLogger.shared.handlePhoneMessage(message) { return } + Task { await WatchLogger.shared.log("⌚️ Watch received data: \(message)") } @@ -225,9 +227,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. From 6aed89eb8335589077a1ef5c89e7803efc64ba1b Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:58:19 +0200 Subject: [PATCH 30/48] Ingest structured watch logs on the phone --- Trio.xcodeproj/project.pbxproj | 4 + Trio/Sources/Logger/Store/AppLogStore.swift | 34 ---- .../Sources/Logger/Watch/WatchLogIngest.swift | 176 ++++++++++++++++++ .../WatchManager/AppleWatchManager.swift | 9 +- TrioTests/Logging/WatchLogIngestTests.swift | 4 +- 5 files changed, 189 insertions(+), 38 deletions(-) create mode 100644 Trio/Sources/Logger/Watch/WatchLogIngest.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 889e65c9f..0f04f40d7 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -152,6 +152,7 @@ 536B4DB89FCFE9188F4DFE5D /* SensitiveValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCB6F0602E069F5B4F83EDC /* SensitiveValues.swift */; }; 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */; }; 659C83549641F3FCC56F30AB /* ReportProblemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7373EC0CC3C059178CB967B4 /* ReportProblemView.swift */; }; + 6DB56C9F3F74834C536E9199 /* WatchLogIngest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 921084A4E9558519903842AE /* WatchLogIngest.swift */; }; 7BA97B74F25D6A1F5D853EFB /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F602845F4994CA0A57286EE6 /* ShareSheet.swift */; }; 8B735B34F861E3AE08F5C9BE /* WatchLogRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75C18518C0B49FB16C05F355 /* WatchLogRing.swift */; }; 91AE5F35FC765DFE4C1A2589 /* AppLogStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB223ADEDD0EB920F7BC209 /* AppLogStoreTests.swift */; }; @@ -1629,6 +1630,7 @@ 7DB8B37A782E6D75420DB2FC /* LogExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExport.swift; sourceTree = ""; }; 7E2CF3BABA4BF1C48CC96FD7 /* LiveActivityGlucoseStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityGlucoseStyle.swift; sourceTree = ""; }; 7FBFFB8EF42D847AFFDA6C05 /* LogFileHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFileHeader.swift; sourceTree = ""; }; + 921084A4E9558519903842AE /* WatchLogIngest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogIngest.swift; sourceTree = ""; }; ABB5F206A024F911C4E145D5 /* LogSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogSink.swift; sourceTree = ""; }; C7BC1040D2F0BD84E0718784 /* TrioLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioLoggerTests.swift; sourceTree = ""; }; CAF80D3953C10DCDF5D80C27 /* LogExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExportTests.swift; sourceTree = ""; }; @@ -3961,6 +3963,7 @@ children = ( F5D79872B74ADDBDD11682D0 /* WatchLogEntry.swift */, 75C18518C0B49FB16C05F355 /* WatchLogRing.swift */, + 921084A4E9558519903842AE /* WatchLogIngest.swift */, ); path = Watch; sourceTree = ""; @@ -6438,6 +6441,7 @@ 659C83549641F3FCC56F30AB /* ReportProblemView.swift in Sources */, E8C6B60FFBA9D89DE3BCCA99 /* WatchLogEntry.swift in Sources */, 92DE66B4776804F0DD8A2925 /* WatchLogRing.swift in Sources */, + 6DB56C9F3F74834C536E9199 /* WatchLogIngest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index 7850405ce..6b12f765b 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -458,37 +458,3 @@ extension LogRetentionPolicy { ) } } - -extension AppLogStore { - private static let watchTimestampFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" - return formatter - }() - - static func watchRecords(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 = watchTimestampFormatter - .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: "Watch", - message: message, - file: "WatchLogger", - function: "", - line: 0, - launchID: LaunchID.current - ) - } - } -} diff --git a/Trio/Sources/Logger/Watch/WatchLogIngest.swift b/Trio/Sources/Logger/Watch/WatchLogIngest.swift new file mode 100644 index 000000000..f28928d42 --- /dev/null +++ b/Trio/Sources/Logger/Watch/WatchLogIngest.swift @@ -0,0 +1,176 @@ +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) + + 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 + @discardableResult func ingest(_ batch: Batch) -> UInt64 { + let ack = queue.sync { + defer { try? FileManager.default.removeItem(at: batch.file) } + var lastSeqs = defaults.dictionary(forKey: Self.lastSeqKey) as? [String: NSNumber] ?? [:] + let previous = lastSeqs[batch.installID]?.uint64Value ?? 0 + 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) + + let ack = max(previous, batch.lastSeq, fresh.last?.seq ?? 0) + lastSeqs[batch.installID] = NSNumber(value: ack) + defaults.set(lastSeqs, forKey: Self.lastSeqKey) + return ack + } + ingestedBatches.withLock { $0 += 1 } + return ack + } + + 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 + } + DispatchQueue.global(qos: .utility).async { + let ack = 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/Services/WatchManager/AppleWatchManager.swift b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift index 0dc446375..cc2743603 100644 --- a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift +++ b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift @@ -636,7 +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 { - AppLogStore.watchRecords(from: logs).forEach(TrioLogger.sink.accept) + WatchLogIngest.legacyRecords(from: logs).forEach(TrioLogger.sink.accept) + if message.count == 1 { return } } Task { @MainActor [weak self] in @@ -776,7 +777,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { if let logs = userInfo["watchLogs"] as? String { - AppLogStore.watchRecords(from: logs).forEach(TrioLogger.sink.accept) + WatchLogIngest.legacyRecords(from: logs).forEach(TrioLogger.sink.accept) } if let snoozeMinutes = userInfo[WatchMessageKeys.snoozeDuration] as? Int { @@ -788,6 +789,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) { diff --git a/TrioTests/Logging/WatchLogIngestTests.swift b/TrioTests/Logging/WatchLogIngestTests.swift index e8426a8cd..37bc86d8a 100644 --- a/TrioTests/Logging/WatchLogIngestTests.swift +++ b/TrioTests/Logging/WatchLogIngestTests.swift @@ -13,14 +13,14 @@ import Testing [2026-01-02T03:04:06+0000] [WatchLogger.swift:40] flush() → third """ - let records = AppLogStore.watchRecords(from: payload, receivedAt: received) + 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" && $0.level == .debug && $0.file == "WatchLogger" }) + #expect(records.allSatisfy { $0.category == "Watch/Legacy" && $0.level == .debug && $0.file == "Watch" }) #expect(records.map(\.timestamp) == [ Date(timeIntervalSince1970: 1_767_323_045), received, From 85b756aa286e70835285281f64ba4b100484b484 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:58:19 +0200 Subject: [PATCH 31/48] Label watch lines in exports --- Trio/Sources/Logger/Export/LogExport.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Trio/Sources/Logger/Export/LogExport.swift b/Trio/Sources/Logger/Export/LogExport.swift index fe2fecdab..f81295c7e 100644 --- a/Trio/Sources/Logger/Export/LogExport.swift +++ b/Trio/Sources/Logger/Export/LogExport.swift @@ -37,6 +37,7 @@ final class LogExport: Injectable { private let traceSubsystems: [String: [String]] private let coreDataStack: CoreDataStack? private let storedValues: SensitiveValues + private let requestWatchLogs: (() async -> Void)? init(resolver: Resolver) { appLog = { TrioLogger.sink } @@ -44,6 +45,7 @@ final class LogExport: Injectable { traceSubsystems = OSLogCapture.subsystems coreDataStack = CoreDataStack.shared storedValues = SensitiveValues() + requestWatchLogs = { await WatchLogIngest.shared.requestLatest() } injectServices(resolver) } @@ -53,13 +55,15 @@ final class LogExport: Injectable { pump: DeviceManager?, cgm: DeviceManager?, storedValues: SensitiveValues = SensitiveValues(), - coreDataStack: CoreDataStack? = nil + 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 } @@ -140,6 +144,9 @@ final class LogExport: Injectable { } 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)) @@ -199,6 +206,10 @@ final class LogExport: Injectable { .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)") } From 96f8762d1f48a70676903bfe800206379ea901ad Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:00:40 +0200 Subject: [PATCH 32/48] Clean up watch log messages --- Trio Watch App Extension/TrioWatchApp.swift | 2 + Trio Watch App Extension/WatchLogger.swift | 15 +- .../WatchState+Requests.swift | 136 ++++-------------- Trio Watch App Extension/WatchState.swift | 113 +++------------ 4 files changed, 62 insertions(+), 204 deletions(-) diff --git a/Trio Watch App Extension/TrioWatchApp.swift b/Trio Watch App Extension/TrioWatchApp.swift index af2e17b11..36acac18e 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,6 +15,7 @@ import UserNotifications } .onChange(of: scenePhase) { _, newScenePhase in if newScenePhase == .background { + 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 69b51085b..12d653fd4 100644 --- a/Trio Watch App Extension/WatchLogger.swift +++ b/Trio Watch App Extension/WatchLogger.swift @@ -17,6 +17,7 @@ final class WatchLogger: Sendable { 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 { @@ -50,12 +51,18 @@ final class WatchLogger: Sendable { log(.error, category, Self.message(message, error: error)) } - func log(_ message: String, force _: Bool = false) async { - debug(.data, message) + // 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() + 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 + } } - func persistLogsLocally() async {} - func didEnterBackground() { Task { [shipper, pending] in await shipper.drain(pending) diff --git a/Trio Watch App Extension/WatchState+Requests.swift b/Trio Watch App Extension/WatchState+Requests.swift index eefd5b5f1..6e44d8371 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 e8bfa9830..389efd87d 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,26 +145,16 @@ 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)") - } } } } @@ -191,18 +163,15 @@ import WatchConnectivity func session(_: WCSession, didReceiveMessage message: [String: Any]) { if WatchLogger.shared.handlePhoneMessage(message) { return } - Task { - await WatchLogger.shared.log("⌚️ Watch received data: \(message)") - } - // 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 @@ -213,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 @@ -242,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 } @@ -252,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 } @@ -260,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 } @@ -272,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) } } @@ -284,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 { @@ -311,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 @@ -323,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 @@ -348,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 @@ -385,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 } @@ -396,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 } @@ -408,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 @@ -420,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 @@ -431,10 +377,6 @@ import WatchConnectivity return } - Task { - await WatchLogger.shared.log("⌚️ Finalizing pending data") - } - // Actually set your main UI properties here processRawDataForWatchState(pendingData) @@ -446,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 { From e0d6af93a23f8aacf9b87f9462f6ff41c25b3fb3 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:15:14 +0200 Subject: [PATCH 33/48] Add watch log tests --- Trio.xcodeproj/project.pbxproj | 4 + TrioTests/Logging/LogExportTests.swift | 41 +++++ TrioTests/Logging/WatchLogIngestTests.swift | 175 +++++++++++++++++++- TrioTests/Logging/WatchLogRingTests.swift | 150 +++++++++++++++++ 4 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 TrioTests/Logging/WatchLogRingTests.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 0f04f40d7..28215acd6 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 03B15F4D3077CD5400DFEBE4 /* SyaiKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */; }; 03B15F4E3077CD6100DFEBE4 /* SyaiKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4C3077CD5400DFEBE4 /* SyaiKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 03B15F4F3077CD6100DFEBE4 /* SyaiKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 03B15F4A3077CD4B00DFEBE4 /* SyaiKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 03DE042795C2AFA00C2CCE9E /* WatchLogRingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA5E2B256AC7F8447B1F1C3 /* WatchLogRingTests.swift */; }; 041D1E995A6AE92E9289DC49 /* TreatmentsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D1A7CA8C10C4403D4BBFA7 /* TreatmentsDataFlow.swift */; }; 0D9A5E34A899219C5C4CDFAF /* HistoryStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9455FA2D92E77A6C4AFED8A3 /* HistoryStateModel.swift */; }; 10DAA318F47843A4B894173D /* ContainerResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98546920BB7247B0AC73449A /* ContainerResolutionTests.swift */; }; @@ -2324,6 +2325,7 @@ FBB3BAE7494CB771ABAC7B8B /* ISFEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorRootView.swift; sourceTree = ""; }; FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutPreferences.swift; sourceTree = ""; }; FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; + FEA5E2B256AC7F8447B1F1C3 /* WatchLogRingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogRingTests.swift; sourceTree = ""; }; FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = ""; }; CA0924000000000000000001 /* GRICalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRICalculator.swift; sourceTree = ""; }; CA0924000000000000000003 /* GRIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRIView.swift; sourceTree = ""; }; @@ -3954,6 +3956,7 @@ 510C461CD60549BDF7E57BE9 /* LoggingStressFixtures.swift */, 5A4F4087B27F7D5819A37272 /* LoggingVolumeStressTests.swift */, 258BD00B3EAADF1A62EFC884 /* LoggingRobustnessStressTests.swift */, + FEA5E2B256AC7F8447B1F1C3 /* WatchLogRingTests.swift */, ); path = Logging; sourceTree = ""; @@ -6563,6 +6566,7 @@ 3F30764ED6567CB6B1DF68C1 /* LoggingStressFixtures.swift in Sources */, EAE1104EF2313F9A0041C746 /* LoggingVolumeStressTests.swift in Sources */, F07C663CC423264E609D706C /* LoggingRobustnessStressTests.swift in Sources */, + 03DE042795C2AFA00C2CCE9E /* WatchLogRingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TrioTests/Logging/LogExportTests.swift b/TrioTests/Logging/LogExportTests.swift index 2f5521a18..6bc777d59 100644 --- a/TrioTests/Logging/LogExportTests.swift +++ b/TrioTests/Logging/LogExportTests.swift @@ -1,6 +1,7 @@ import CoreData import Foundation import LoopKit +import os import Testing @testable import Trio @@ -152,6 +153,46 @@ import Testing } } + @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) } diff --git a/TrioTests/Logging/WatchLogIngestTests.swift b/TrioTests/Logging/WatchLogIngestTests.swift index 37bc86d8a..b5fe2e30f 100644 --- a/TrioTests/Logging/WatchLogIngestTests.swift +++ b/TrioTests/Logging/WatchLogIngestTests.swift @@ -4,7 +4,180 @@ import Testing @testable import Trio @Suite("Watch Log Ingest Tests") struct WatchLogIngestTests { - @Test("Each payload line becomes a watch record in order") func parsesLines() { + 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("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() 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 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() throws { + let fixture = try Fixture() + + let acks = try [ + 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 relaunched.ingest(fixture.stage(Self.entries(4 ... 6))) + #expect(ack == 6) + #expect(fixture.sink.snapshot().map(\.message).suffix(1) == ["entry 6"]) + } + + @Test("Staging copies the file and removes it after ingest") func stagesCopy() 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) + + 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 diff --git a/TrioTests/Logging/WatchLogRingTests.swift b/TrioTests/Logging/WatchLogRingTests.swift new file mode 100644 index 000000000..751743098 --- /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) + } +} From 9969176bc5875974e1e2ad30398a844b8bbc2537 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:40:25 +0200 Subject: [PATCH 34/48] Acknowledge watch logs only once stored --- .../Sources/Logger/Watch/WatchLogIngest.swift | 49 +++++++++++++------ TrioTests/Logging/WatchLogIngestTests.swift | 49 ++++++++++++++++--- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/Trio/Sources/Logger/Watch/WatchLogIngest.swift b/Trio/Sources/Logger/Watch/WatchLogIngest.swift index f28928d42..4554b273b 100644 --- a/Trio/Sources/Logger/Watch/WatchLogIngest.swift +++ b/Trio/Sources/Logger/Watch/WatchLogIngest.swift @@ -20,6 +20,7 @@ final class WatchLogIngest: @unchecked Sendable { 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 @@ -53,20 +54,35 @@ final class WatchLogIngest: @unchecked Sendable { return Batch(file: staged, installID: installID, firstSeq: firstSeq, lastSeq: lastSeq) } - // Returns the seq to acknowledge to the watch - @discardableResult func ingest(_ batch: Batch) -> UInt64 { + // 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 { - defer { try? FileManager.default.removeItem(at: batch.file) } + let ack = max(lastSeq(for: batch.installID), fresh.last?.seq ?? 0) var lastSeqs = defaults.dictionary(forKey: Self.lastSeqKey) as? [String: NSNumber] ?? [:] - let previous = lastSeqs[batch.installID]?.uint64Value ?? 0 - 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) - - let ack = max(previous, batch.lastSeq, fresh.last?.seq ?? 0) lastSeqs[batch.installID] = NSNumber(value: ack) defaults.set(lastSeqs, forKey: Self.lastSeqKey) return ack @@ -75,14 +91,19 @@ final class WatchLogIngest: @unchecked Sendable { 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 } - DispatchQueue.global(qos: .utility).async { - let ack = self.ingest(batch) + Task.detached(priority: .utility) { + let ack = await self.ingest(batch) Self.send([WatchLogTransfer.ack: NSNumber(value: ack), WatchLogTransfer.installID: batch.installID]) } } diff --git a/TrioTests/Logging/WatchLogIngestTests.swift b/TrioTests/Logging/WatchLogIngestTests.swift index b5fe2e30f..6d076700c 100644 --- a/TrioTests/Logging/WatchLogIngestTests.swift +++ b/TrioTests/Logging/WatchLogIngestTests.swift @@ -63,6 +63,23 @@ import Testing } } + @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( @@ -89,7 +106,7 @@ import Testing #expect(abs((decoded?.date.timeIntervalSince(precise.date)) ?? 1) < 0.0005) } - @Test("Ingested entries keep their level, category and date") func mapsRecords() throws { + @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) @@ -104,7 +121,7 @@ import Testing ) } - let ack = try fixture.ingest.ingest(fixture.stage(entries)) + let ack = try await fixture.ingest.ingest(fixture.stage(entries)) let records = fixture.sink.snapshot() #expect(ack == 5) @@ -115,10 +132,10 @@ import Testing #expect(records.allSatisfy { $0.file == "Watch" }) } - @Test("Repeated and overlapping batches are ingested once per install") func dedupes() throws { + @Test("Repeated and overlapping batches are ingested once per install") func dedupes() async throws { let fixture = try Fixture() - let acks = try [ + 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))), @@ -131,12 +148,30 @@ import Testing let sink = fixture.sink let relaunched = WatchLogIngest(defaults: fixture.defaults, sink: { sink }) - let ack = try relaunched.ingest(fixture.stage(Self.entries(4 ... 6))) + 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("Staging copies the file and removes it after ingest") func stagesCopy() throws { + @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)) @@ -146,7 +181,7 @@ import Testing #expect(batch.installID == "install-a") #expect(batch.firstSeq == 1 && batch.lastSeq == 2) - fixture.ingest.ingest(batch) + await fixture.ingest.ingest(batch) #expect(fixture.sink.snapshot().count == 2) #expect(!FileManager.default.fileExists(atPath: batch.file.path)) } From 2202523166f60aaeccf328f5f9334496c6794626 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:11:36 +0200 Subject: [PATCH 35/48] Fix log retention at 8 days --- Trio/Sources/Application/TrioApp.swift | 3 --- .../Localizations/Main/Localizable.xcstrings | 21 ------------------- .../Logger/Contracts/LogRetentionPolicy.swift | 2 +- Trio/Sources/Logger/Store/AppLogStore.swift | 21 ------------------- Trio/Sources/Models/TrioSettings.swift | 11 ---------- .../AppDiagnosticsStateModel.swift | 12 ----------- .../View/AppDiagnosticsRootView.swift | 17 --------------- 7 files changed, 1 insertion(+), 86 deletions(-) diff --git a/Trio/Sources/Application/TrioApp.swift b/Trio/Sources/Application/TrioApp.swift index c0cb28685..5058b745e 100644 --- a/Trio/Sources/Application/TrioApp.swift +++ b/Trio/Sources/Application/TrioApp.swift @@ -67,9 +67,6 @@ extension Notification.Name { private func loadServices() { resolver.resolve(AppearanceManager.self)!.setupGlobalAppearance() - if let settingsManager = resolver.resolve(SettingsManager.self), let broadcaster = resolver.resolve(Broadcaster.self) { - AppLogStore.shared.observeSettings(of: settingsManager, broadcaster: broadcaster) - } _ = resolver.resolve(DeviceDataManager.self)! _ = resolver.resolve(APSManager.self)! _ = resolver.resolve(FetchGlucoseManager.self)! diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index d253013b4..0f2ab1b97 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -1088,27 +1088,12 @@ } } } - }, - "3 days" : { - - }, - "Also keep detailed debug messages in the app log." : { - }, "Ask on Discord" : { }, "Ask on Facebook" : { - }, - "How long Trio keeps its app log on this device." : { - - }, - "Log Retention" : { - - }, - "Logging" : { - }, "Open a GitHub Issue" : { @@ -1121,9 +1106,6 @@ }, "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." : { - }, - "Verbose Logging" : { - }, "Choose the preferred lower threshold for Time in Range statistics." : { "comment" : "Short description of the lower range threshold setting.", @@ -23487,9 +23469,6 @@ } } } - }, - "1 day" : { - }, "1 U and over" : { "comment" : "Name to display for the \"1 U and over\" bolus display threshold option.", diff --git a/Trio/Sources/Logger/Contracts/LogRetentionPolicy.swift b/Trio/Sources/Logger/Contracts/LogRetentionPolicy.swift index d01387a88..92e720bfd 100644 --- a/Trio/Sources/Logger/Contracts/LogRetentionPolicy.swift +++ b/Trio/Sources/Logger/Contracts/LogRetentionPolicy.swift @@ -6,7 +6,7 @@ struct LogRetentionPolicy: Equatable { var maxRows: Int static let `default` = LogRetentionPolicy( - maxAge: 7 * 24 * 60 * 60, + maxAge: 8 * 24 * 60 * 60, persistsDebug: true, maxRows: 250_000 ) diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index 6b12f765b..4efc6e4a1 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -151,11 +151,6 @@ final class AppLogStore: LogSink, @unchecked Sendable { } } - func observeSettings(of settingsManager: SettingsManager, broadcaster: Broadcaster) { - apply(LogRetentionPolicy(settings: settingsManager.settings)) - broadcaster.register(SettingsObserver.self, observer: self) - } - private func flushPending() { guard let context else { return } let batch = pending.withLock { state -> [LogRecord] in @@ -442,19 +437,3 @@ final class AppLogStore: LogSink, @unchecked Sendable { return container } } - -extension AppLogStore: SettingsObserver { - func settingsDidChange(_ settings: TrioSettings) { - apply(LogRetentionPolicy(settings: settings)) - } -} - -extension LogRetentionPolicy { - init(settings: TrioSettings) { - self.init( - maxAge: TimeInterval(max(1, settings.logRetentionDays)) * 24 * 60 * 60, - persistsDebug: settings.logVerboseEnabled, - maxRows: LogRetentionPolicy.default.maxRows - ) - } -} diff --git a/Trio/Sources/Models/TrioSettings.swift b/Trio/Sources/Models/TrioSettings.swift index e03f4188b..797a6bf0a 100644 --- a/Trio/Sources/Models/TrioSettings.swift +++ b/Trio/Sources/Models/TrioSettings.swift @@ -89,9 +89,6 @@ struct TrioSettings: JSON, Equatable, Encodable { /// Controls whether watchface data transmission is enabled var isWatchfaceDataEnabled: Bool = false - var logRetentionDays: Int = 7 - var logVerboseEnabled: Bool = true - /// Computed property that groups all Garmin settings into a single struct var garminSettings: GarminWatchSettings { get { @@ -405,14 +402,6 @@ extension TrioSettings: Decodable { settings.isWatchfaceDataEnabled = isWatchfaceDataEnabled } - if let logRetentionDays = try? container.decode(Int.self, forKey: .logRetentionDays) { - settings.logRetentionDays = logRetentionDays - } - - if let logVerboseEnabled = try? container.decode(Bool.self, forKey: .logVerboseEnabled) { - settings.logVerboseEnabled = logVerboseEnabled - } - self = settings } } diff --git a/Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift b/Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift index df3e32301..92f9e3020 100644 --- a/Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift +++ b/Trio/Sources/Modules/AppDiagnostics/AppDiagnosticsStateModel.swift @@ -7,20 +7,8 @@ extension AppDiagnostics { var diagnosticsSharingOption: DiagnosticsSharingOption = .full - // MARK: - Logging - - var logRetentionDays = 7 { - didSet { settingsManager.settings.logRetentionDays = logRetentionDays } - } - - var logVerboseEnabled = true { - didSet { settingsManager.settings.logVerboseEnabled = logVerboseEnabled } - } - override func subscribe() { loadDiagnostics() - logRetentionDays = settingsManager.settings.logRetentionDays - logVerboseEnabled = settingsManager.settings.logVerboseEnabled } /// Derives the 3-state option from the two underlying flags. Both diff --git a/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift b/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift index 0384334f4..a910643ee 100644 --- a/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift +++ b/Trio/Sources/Modules/AppDiagnostics/View/AppDiagnosticsRootView.swift @@ -64,23 +64,6 @@ extension AppDiagnostics { NavigationLink("Privacy details") { TelemetryPrivacyView() } }.listRowBackground(Color.chart) - Section( - header: Text("Logging"), - footer: Text("How long Trio keeps its app log on this device.") - ) { - Picker("Log Retention", selection: $state.logRetentionDays) { - Text("1 day").tag(1) - Text("3 days").tag(3) - Text("7 days").tag(7) - } - }.listRowBackground(Color.chart) - - Section( - footer: Text("Also keep detailed debug messages in the app log.") - ) { - Toggle("Verbose Logging", isOn: $state.logVerboseEnabled) - }.listRowBackground(Color.chart) - Section { VStack(alignment: .leading, spacing: 8) { Text("Why does Trio collect this data?").bold() From 9f0b8faf3f9c2493cc3ef92d32f0eac181499d29 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:33:28 +0200 Subject: [PATCH 36/48] Clean up log messages added on dev --- .../Adjustments/View/Overrides/EditOverrideForm.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift index a2a3b19a8..e70dd9f85 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/EditOverrideForm.swift @@ -539,10 +539,7 @@ 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 { warning(.treatments, "Saving edited override failed", error: error) From ae755fb4f55ea62c310e3cae2bc57424a7d88636 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:34:05 +0200 Subject: [PATCH 37/48] Add 90-day long-run logging stress test --- Trio.xcodeproj/project.pbxproj | 4 + .../Logging/LoggingLongRunStressTests.swift | 1317 +++++++++++++++++ 2 files changed, 1321 insertions(+) create mode 100644 TrioTests/Logging/LoggingLongRunStressTests.swift diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 28215acd6..cac7c4de3 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -149,6 +149,7 @@ 38B4E1780D264CEBEB5DBCB3 /* LogExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF80D3953C10DCDF5D80C27 /* LogExportTests.swift */; }; 3F30764ED6567CB6B1DF68C1 /* LoggingStressFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510C461CD60549BDF7E57BE9 /* LoggingStressFixtures.swift */; }; 4ABD323927D718C0177C26B5 /* OSLogCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46913F3B81BE2E45F4DC632D /* OSLogCapture.swift */; }; + 4C0A9E5D31B7F26E8D1A0C42 /* LoggingLongRunStressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3B19D06A4C85F2E91D3B57 /* LoggingLongRunStressTests.swift */; }; 4E3B6212E43A3E90DD735B30 /* AppLog.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 163DFE6AE312CC4BBDE18D71 /* AppLog.xcdatamodeld */; }; 536B4DB89FCFE9188F4DFE5D /* SensitiveValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCB6F0602E069F5B4F83EDC /* SensitiveValues.swift */; }; 5C55695D8C9185BDB57B3E7B /* LogRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3084B00ADBA601CDBA8AB7 /* LogRetentionPolicy.swift */; }; @@ -1630,6 +1631,7 @@ 75C18518C0B49FB16C05F355 /* WatchLogRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogRing.swift; sourceTree = ""; }; 7DB8B37A782E6D75420DB2FC /* LogExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExport.swift; sourceTree = ""; }; 7E2CF3BABA4BF1C48CC96FD7 /* LiveActivityGlucoseStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityGlucoseStyle.swift; sourceTree = ""; }; + 7E3B19D06A4C85F2E91D3B57 /* LoggingLongRunStressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingLongRunStressTests.swift; sourceTree = ""; }; 7FBFFB8EF42D847AFFDA6C05 /* LogFileHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFileHeader.swift; sourceTree = ""; }; 921084A4E9558519903842AE /* WatchLogIngest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLogIngest.swift; sourceTree = ""; }; ABB5F206A024F911C4E145D5 /* LogSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogSink.swift; sourceTree = ""; }; @@ -3957,6 +3959,7 @@ 5A4F4087B27F7D5819A37272 /* LoggingVolumeStressTests.swift */, 258BD00B3EAADF1A62EFC884 /* LoggingRobustnessStressTests.swift */, FEA5E2B256AC7F8447B1F1C3 /* WatchLogRingTests.swift */, + 7E3B19D06A4C85F2E91D3B57 /* LoggingLongRunStressTests.swift */, ); path = Logging; sourceTree = ""; @@ -6567,6 +6570,7 @@ EAE1104EF2313F9A0041C746 /* LoggingVolumeStressTests.swift in Sources */, F07C663CC423264E609D706C /* LoggingRobustnessStressTests.swift in Sources */, 03DE042795C2AFA00C2CCE9E /* WatchLogRingTests.swift in Sources */, + 4C0A9E5D31B7F26E8D1A0C42 /* LoggingLongRunStressTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TrioTests/Logging/LoggingLongRunStressTests.swift b/TrioTests/Logging/LoggingLongRunStressTests.swift new file mode 100644 index 000000000..1ad417bc2 --- /dev/null +++ b/TrioTests/Logging/LoggingLongRunStressTests.swift @@ -0,0 +1,1317 @@ +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 + } + } + + 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 7 days", + 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 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 + )) + } + 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)), + "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((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 deviceLatencies = acceptLatencies { index in + deviceLog.log( + managerIdentifier: Fixtures.pumpManager, + deviceIdentifier: Fixtures.pumpDevice, + type: .send, + message: "#launch-dev.\(index) logged during purge" + ) + } + _ = 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)), + "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((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() + } +} From 7383cb24eef457d8f380ded979938c928d75b8a1 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:00:36 +0200 Subject: [PATCH 38/48] Purge the device log a day at a time --- .../Logger/DeviceLog/DeviceLogStore.swift | 104 +++++++++++++----- TrioTests/Logging/DeviceLogStoreTests.swift | 38 +++++++ .../Logging/LoggingLongRunStressTests.swift | 29 +++++ 3 files changed, 145 insertions(+), 26 deletions(-) diff --git a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift index 361a96e50..dcbc7afc6 100644 --- a/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift +++ b/Trio/Sources/Logger/DeviceLog/DeviceLogStore.swift @@ -5,6 +5,7 @@ import UIKit final class DeviceLogStore: @unchecked Sendable { static let bufferLimit = 5000 + static let purgeStep: TimeInterval = 24 * 60 * 60 private struct PendingEntry { let managerIdentifier: String @@ -34,6 +35,8 @@ final class DeviceLogStore: @unchecked Sendable { 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, @@ -108,30 +111,43 @@ final class DeviceLogStore: @unchecked Sendable { func entries(in window: DateInterval, managerIdentifier: String? = nil) async -> [StoredDeviceLogEntry] { await withCheckedContinuation { continuation in queue.async { - guard let deviceLog = self.deviceLog else { - continuation.resume(returning: self.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 - ) - }) + // 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 } - 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 }) - } + 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 }) } } @@ -193,8 +209,26 @@ final class DeviceLogStore: @unchecked Sendable { ) } buffer.removeAll() - deviceLog.purgeLogEntries(before: Date().addingTimeInterval(-maxAge)) 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() { @@ -213,22 +247,40 @@ final class DeviceLogStore: @unchecked Sendable { static func preflight(_ url: URL) -> Bool { guard FileManager.default.isWritableStore(at: url) else { return false } - return autoreleasepool { + 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 false } + 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 - let isReadOnly = coordinator.persistentStores.contains(where: \.isReadOnly) for store in coordinator.persistentStores { try? coordinator.remove(store) } - return loadError == nil && !isReadOnly + return result } } } diff --git a/TrioTests/Logging/DeviceLogStoreTests.swift b/TrioTests/Logging/DeviceLogStoreTests.swift index ad6627ce6..7a83000d1 100644 --- a/TrioTests/Logging/DeviceLogStoreTests.swift +++ b/TrioTests/Logging/DeviceLogStoreTests.swift @@ -128,6 +128,44 @@ import UIKit #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) } diff --git a/TrioTests/Logging/LoggingLongRunStressTests.swift b/TrioTests/Logging/LoggingLongRunStressTests.swift index 1ad417bc2..6c8350dd8 100644 --- a/TrioTests/Logging/LoggingLongRunStressTests.swift +++ b/TrioTests/Logging/LoggingLongRunStressTests.swift @@ -704,6 +704,21 @@ import Testing } } + // 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 { @@ -1275,6 +1290,11 @@ import Testing 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, @@ -1283,6 +1303,13 @@ import Testing 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() @@ -1298,6 +1325,7 @@ import Testing "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)), @@ -1306,6 +1334,7 @@ import Testing "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) From f6b1618a10fc5ca3a57f9a4c5509795088f4e081 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:00:36 +0200 Subject: [PATCH 39/48] Purge the app log a day at a time --- Trio/Sources/Logger/Store/AppLogStore.swift | 32 ++++++++++++++++--- TrioTests/Logging/AppLogStoreTests.swift | 26 +++++++++++++++ .../Logging/LoggingLongRunStressTests.swift | 12 ++++++- .../Logging/LoggingVolumeStressTests.swift | 14 +++++--- 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/Trio/Sources/Logger/Store/AppLogStore.swift b/Trio/Sources/Logger/Store/AppLogStore.swift index 4efc6e4a1..d3722bd59 100644 --- a/Trio/Sources/Logger/Store/AppLogStore.swift +++ b/Trio/Sources/Logger/Store/AppLogStore.swift @@ -10,6 +10,7 @@ final class AppLogStore: LogSink, @unchecked Sendable { 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" @@ -288,11 +289,32 @@ final class AppLogStore: LogSink, @unchecked Sendable { private func purge() { guard let context else { return } lastPurge = Date() - let maxAge = policy.maxAge + 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 { - deleteEntries(before: Date().addingTimeInterval(-maxAge), in: context) - let newest = NSFetchRequest(entityName: Self.entityName) newest.resultType = .dictionaryResultType newest.propertiesToFetch = ["timestamp"] @@ -308,8 +330,10 @@ final class AppLogStore: LogSink, @unchecked Sendable { 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(NSBatchDeleteRequest(fetchRequest: request)) + try context.execute(delete) } catch { Logger.Category.storage.osLogger.error("App log purge failed: \(String(describing: error), privacy: .public)") } diff --git a/TrioTests/Logging/AppLogStoreTests.swift b/TrioTests/Logging/AppLogStoreTests.swift index f384f4fd3..da2a9dc9d 100644 --- a/TrioTests/Logging/AppLogStoreTests.swift +++ b/TrioTests/Logging/AppLogStoreTests.swift @@ -109,6 +109,32 @@ import UIKit #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) } diff --git a/TrioTests/Logging/LoggingLongRunStressTests.swift b/TrioTests/Logging/LoggingLongRunStressTests.swift index 6c8350dd8..4c038f0c6 100644 --- a/TrioTests/Logging/LoggingLongRunStressTests.swift +++ b/TrioTests/Logging/LoggingLongRunStressTests.swift @@ -1190,7 +1190,7 @@ import Testing } @Test( - "Opening log stores with 7 fresh and 83 stale days purges to 7 days", + "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( @@ -1254,6 +1254,12 @@ import Testing 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(), @@ -1266,6 +1272,8 @@ import Testing 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() @@ -1275,6 +1283,7 @@ import Testing "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)), @@ -1282,6 +1291,7 @@ import Testing "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) diff --git a/TrioTests/Logging/LoggingVolumeStressTests.swift b/TrioTests/Logging/LoggingVolumeStressTests.swift index 329bfd525..cde4c9d67 100644 --- a/TrioTests/Logging/LoggingVolumeStressTests.swift +++ b/TrioTests/Logging/LoggingVolumeStressTests.swift @@ -221,7 +221,13 @@ import Testing let ageStart = Self.clock.now store.apply(.default) - await store.flush() + // 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)", [ @@ -231,7 +237,7 @@ import Testing "sqlite+wal": Fixtures.megabytes(Fixtures.storeSize(url)) ]) #expect(afterAge.count <= LogRetentionPolicy.default.maxRows) - #expect((afterAge.oldest ?? .distantPast) >= Date().addingTimeInterval(-LogRetentionPolicy.default.maxAge - 60)) + #expect((afterAge.oldest ?? .distantPast) >= cutoff) #expect(afterAge.newest == newest) return store } @@ -372,7 +378,7 @@ import Testing } @Test( - "App log store ingests 90 days and purges to the cap, then to 7 days", + "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() @@ -380,7 +386,7 @@ import Testing } @Test( - "Device log store ingests 90 days and purges to 7 days on open", + "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() From d8c098ebd1cdd348687d8c3eb79ede505b483089 Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:49:32 +0200 Subject: [PATCH 40/48] Log errors as plain text --- Trio/Sources/Logger/Core/TrioLogger.swift | 10 +++++++- Trio/Sources/Logger/Logger.swift | 28 ++++------------------- TrioTests/Logging/TrioLoggerTests.swift | 21 +++++++++++++---- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/Trio/Sources/Logger/Core/TrioLogger.swift b/Trio/Sources/Logger/Core/TrioLogger.swift index 2855b0e02..183746ede 100644 --- a/Trio/Sources/Logger/Core/TrioLogger.swift +++ b/Trio/Sources/Logger/Core/TrioLogger.swift @@ -5,6 +5,7 @@ enum TrioLogger { private struct State: Sendable { var sink: any LogSink var installed: Bool + var capture: (any LogSink)? var lastTimestamp = Date.distantPast } @@ -26,6 +27,13 @@ enum TrioLogger { } } + // Tests read records here; the test host app's own install can't replace it mid-test + static func capturing(into sink: any LogSink, _ body: () -> Void) { + state.withLock { $0.capture = sink } + defer { state.withLock { $0.capture = nil } } + body() + } + static func log( _ level: LogLevel, _ category: Logger.Category, @@ -39,7 +47,7 @@ enum TrioLogger { let (timestamp, sink) = state.withLock { state in let timestamp = max(Date(), state.lastTimestamp.addingTimeInterval(0.000_001)) state.lastTimestamp = timestamp - return (timestamp, state.sink) + return (timestamp, state.capture ?? state.sink) } let record = LogRecord( timestamp: timestamp, diff --git a/Trio/Sources/Logger/Logger.swift b/Trio/Sources/Logger/Logger.swift index 691bdfbb9..5361345f3 100644 --- a/Trio/Sources/Logger/Logger.swift +++ b/Trio/Sources/Logger/Logger.swift @@ -30,7 +30,7 @@ func warning( function: String = #function, line: UInt = #line ) { - let message = String(describing: maybeError.loggerError(message: message, withDescription: description)) + let message = composed(message, description: description, error: maybeError) TrioLogger.log(.warning, category, message, file: file, function: function, line: line) } @@ -43,7 +43,7 @@ func error( function: String = #function, line: UInt = #line ) { - let message = String(describing: maybeError.loggerError(message: message, withDescription: description)) + let message = composed(message, description: description, error: maybeError) TrioLogger.log(.error, category, message, file: file, function: function, line: line) } @@ -56,7 +56,7 @@ func fault( function: String = #function, line: UInt = #line ) { - let message = String(describing: maybeError.loggerError(message: message, withDescription: description)) + let message = composed(message, description: description, error: maybeError) TrioLogger.log(.fault, category, message, file: file, function: function, line: line) } @@ -85,26 +85,8 @@ enum Logger { rawValue.capitalizingFirstLetter() } } - - fileprivate enum Error: Swift.Error { - case error(String) - case errorWithInnerError(String, Swift.Error) - case errorWithDescription(String, String) - case errorWithDescriptionAndInnerError(String, String, Swift.Error) - } } -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 func composed(_ message: String, description: String?, error: Swift.Error?) -> String { + [message, description, error.map { String(describing: $0) }].compactMap { $0 }.joined(separator: ": ") } diff --git a/TrioTests/Logging/TrioLoggerTests.swift b/TrioTests/Logging/TrioLoggerTests.swift index 7164ba51d..81a9eb862 100644 --- a/TrioTests/Logging/TrioLoggerTests.swift +++ b/TrioTests/Logging/TrioLoggerTests.swift @@ -5,11 +5,8 @@ import Testing @Suite("Trio Logger Tests", .serialized) struct TrioLoggerTests { private func capture(_ body: () -> Void) -> [LogRecord] { - let previous = TrioLogger.sink let sink = InMemoryLogSink(capacity: 20000) - TrioLogger.install(sink) - defer { TrioLogger.install(previous) } - body() + TrioLogger.capturing(into: sink, body) return sink.snapshot().filter { $0.file == "TrioLoggerTests.swift" } } @@ -40,6 +37,22 @@ import Testing #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) From 9a068c27a2d20dc1b857b3e43476ff63e59dda8f Mon Sep 17 00:00:00 2001 From: dnzxy <48965855+dnzxy@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:35:52 +0200 Subject: [PATCH 41/48] Redact Syai IDs and capture Syai traces --- Trio/Sources/Logger/Export/OSLogCapture.swift | 1 + Trio/Sources/Logger/Export/SensitiveValues.swift | 13 +++++++++++++ TrioTests/Logging/RedactorTests.swift | 6 ++++++ 3 files changed, 20 insertions(+) diff --git a/Trio/Sources/Logger/Export/OSLogCapture.swift b/Trio/Sources/Logger/Export/OSLogCapture.swift index a0ce54e5e..276247a2e 100644 --- a/Trio/Sources/Logger/Export/OSLogCapture.swift +++ b/Trio/Sources/Logger/Export/OSLogCapture.swift @@ -25,6 +25,7 @@ enum OSLogCapture { "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"] ] diff --git a/Trio/Sources/Logger/Export/SensitiveValues.swift b/Trio/Sources/Logger/Export/SensitiveValues.swift index 60d33e760..b18be256f 100644 --- a/Trio/Sources/Logger/Export/SensitiveValues.swift +++ b/Trio/Sources/Logger/Export/SensitiveValues.swift @@ -4,6 +4,7 @@ import Foundation import G7SensorKit import LibreTransmitter import LoopKit +import SyaiKit import TidepoolServiceKit struct SensitiveValues { @@ -53,6 +54,10 @@ struct SensitiveValues { (["sharedKey"], .secret), (["shareUsername"], .account) ], + "SyaiCGMManager": [ + (["sensors", "activeMAC"], .device), + (["sensors", "pendingBind", "mac"], .device) + ], "LibreLoopCGMManager": [ (["sensorSerial"], .cgmSerial), (["bleAddress"], .device), @@ -163,6 +168,14 @@ struct SensitiveValues { 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) diff --git a/TrioTests/Logging/RedactorTests.swift b/TrioTests/Logging/RedactorTests.swift index 3c16fad47..748404617 100644 --- a/TrioTests/Logging/RedactorTests.swift +++ b/TrioTests/Logging/RedactorTests.swift @@ -86,6 +86,12 @@ import Testing "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"], From bc66515b46359205021ace89a3e0fcf350c58521 Mon Sep 17 00:00:00 2001 From: Lamercho <33007090+Lamercho@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:15:30 +0200 Subject: [PATCH 42/48] Re-run CI From 065f5f3102df7d03367ca963cb7e1b4b3d30822a Mon Sep 17 00:00:00 2001 From: Lamercho <33007090+Lamercho@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:01:18 +0200 Subject: [PATCH 43/48] Remove code comments --- LiveActivity/LiveActivity.swift | 2 -- LiveActivity/Views/LiveActivityView.swift | 5 ----- 2 files changed, 7 deletions(-) diff --git a/LiveActivity/LiveActivity.swift b/LiveActivity/LiveActivity.swift index da5a99c02..83850653e 100644 --- a/LiveActivity/LiveActivity.swift +++ b/LiveActivity/LiveActivity.swift @@ -49,8 +49,6 @@ struct LiveActivity: Widget { .widgetURL(URL(string: "Trio://")) .keylineTint(glucoseColor) .contentMargins(.horizontal, 0, for: .minimal) - // compactLeading keeps the system trailing margin: with 0 the trend arrow is cut off in a narrow island - // (iOS 27 landscape). cf. https://github.com/nightscout/Trio/issues/1657 .contentMargins(.leading, 0, for: .compactTrailing) } diff --git a/LiveActivity/Views/LiveActivityView.swift b/LiveActivity/Views/LiveActivityView.swift index 82a71e02e..522b9409e 100644 --- a/LiveActivity/Views/LiveActivityView.swift +++ b/LiveActivity/Views/LiveActivityView.swift @@ -264,9 +264,6 @@ struct LiveActivityCompactLeadingView: View { var glucoseColor: Color var body: some View { - // iOS 27 narrows the compact views (several Live Activities, landscape): scale the text down instead of - // truncating it to "…"; no extra leading padding, so the glucose value keeps more room - // cf. https://github.com/nightscout/Trio/issues/1657 LiveActivityBGAndTrendView(context: context, size: .compact, glucoseColor: glucoseColor) .lineLimit(1) .minimumScaleFactor(0.5) @@ -278,8 +275,6 @@ struct LiveActivityCompactTrailingView: View { var glucoseColor: Color var body: some View { - // scale the delta down instead of truncating it (see LiveActivityCompactLeadingView) - // cf. https://github.com/nightscout/Trio/issues/1657 LiveActivityGlucoseDeltaLabelView(context: context, glucoseColor: glucoseColor).padding(.trailing, 4) .lineLimit(1) .minimumScaleFactor(0.5) From a60d8b3b84a5f8eec577805b87993ab6861d01e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 25 Oct 2026 20:20:55 +0000 Subject: [PATCH 44/48] CI: Bump APP_DEV_VERSION to 1.0.2.28 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index f5ca7fa54..7d17f484b 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.27 +APP_DEV_VERSION = 1.0.2.28 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 7ff1f777e7921feed7370298da92f5ab29665e8d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 25 Oct 2026 21:26:35 +0000 Subject: [PATCH 45/48] CI: Bump APP_DEV_VERSION to 1.0.2.29 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 7d17f484b..b54fb79d1 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.28 +APP_DEV_VERSION = 1.0.2.29 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From 45cd2cacb2604fb5b38fd060e482094d3af74fda Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 26 Oct 2026 20:18:57 +0000 Subject: [PATCH 46/48] CI: Bump APP_DEV_VERSION to 1.0.2.30 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index b54fb79d1..f205e19b5 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.29 +APP_DEV_VERSION = 1.0.2.30 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = From aa074403904abd8a9e5839f160010222d35770ac Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 27 Oct 2026 07:56:52 +0100 Subject: [PATCH 47/48] Align Tai-specific logging with the new logging rules Migrate the remaining legacy log categories in Tai-owned code to the upstream Logger.Category set, rewrite messages as plain sentences with units, log swallowed errors at warning with error:, and replace print and debugPrint calls. Garmin watch command logging now carries a log level. --- Trio/Sources/APS/APSManager.swift | 24 +++---- Trio/Sources/APS/DeviceDataManager.swift | 8 +-- Trio/Sources/APS/FetchGlucoseManager.swift | 62 ++++++++--------- .../OpenAPSSwift/DetermineBasal/AimiB30.swift | 16 ++--- .../AutoISFGlucose+Helpers.swift | 6 +- .../DetermineBasalGenerator.swift | 10 +-- .../Forecasts/ForecastGenerator.swift | 8 +-- Trio/Sources/APS/Storage/CarbsStorage.swift | 2 +- .../APS/Storage/PumpHistoryStorage.swift | 8 +-- .../AdaptProfile/AdaptProfileProvider.swift | 23 +++---- .../AutoISFHistoryStateModel.swift | 4 +- .../View/CustomCGMOptionsView.swift | 2 +- .../CSFEditor/CSFEditorStateModel.swift | 7 +- .../Modules/Export/ExportStateModel.swift | 67 ++++++------------- .../Modules/Export/View/ExportRootView.swift | 23 +++---- .../History/View/HistoryRootView.swift | 2 - .../DeterminationSetup.swift | 2 +- .../ProfileExpirationSetup.swift | 4 +- .../Sources/Modules/Home/HomeStateModel.swift | 5 +- .../ProfileSchedulerProvider.swift | 2 +- .../PumpConfig/PumpConfigStateModel.swift | 5 +- .../BolusCalculationManager.swift | 4 +- .../Services/ProfileScheduleFirer.swift | 39 +++-------- .../SettingsManager/ActiveProfileMirror.swift | 2 +- .../SettingsManager/ProfileSeeder.swift | 8 +-- .../UserNotificationsManager.swift | 17 +++-- .../WatchCommand/WatchCommandProcessor.swift | 50 +++++++------- .../WatchManager/GarminCommandRouter.swift | 10 +-- .../Services/WatchManager/GarminManager.swift | 7 +- .../CreateCustomTempTargetIntent.swift | 5 +- .../ScheduledTempTargetHelper.swift | 20 ++---- .../TempPresetsIntentRequest.swift | 5 +- .../GarminCommandRouterTests.swift | 2 +- .../WatchCommandTestDoubles.swift | 9 +-- 34 files changed, 187 insertions(+), 281 deletions(-) diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index de688ee9a..544778ed2 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -422,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 @@ -436,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 @@ -453,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 @@ -774,13 +774,13 @@ 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)) } } @@ -837,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) } @@ -851,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 diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index be771daac..49bdcf133 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -111,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 @@ -186,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 diff --git a/Trio/Sources/APS/FetchGlucoseManager.swift b/Trio/Sources/APS/FetchGlucoseManager.swift index 46d1748c5..762a0549a 100644 --- a/Trio/Sources/APS/FetchGlucoseManager.swift +++ b/Trio/Sources/APS/FetchGlucoseManager.swift @@ -353,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" @@ -516,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: @@ -537,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 @@ -547,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 } @@ -618,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 } @@ -628,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 } @@ -639,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 } @@ -713,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. @@ -726,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 @@ -762,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)) @@ -779,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 { @@ -787,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 @@ -805,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) } } @@ -821,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 @@ -837,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)) @@ -884,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" ) } @@ -899,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) } } @@ -932,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/OpenAPSSwift/DetermineBasal/AimiB30.swift b/Trio/Sources/APS/OpenAPSSwift/DetermineBasal/AimiB30.swift index ec8115c9c..033b01417 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 953f72017..1a6c7c332 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 bab0b3b07..5dca69849 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 1d266360e..424bc2555 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/Storage/CarbsStorage.swift b/Trio/Sources/APS/Storage/CarbsStorage.swift index 2844c1be3..4161e0f2d 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) } } diff --git a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift index c34a46042..7b3bc27db 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 } diff --git a/Trio/Sources/Modules/AdaptProfile/AdaptProfileProvider.swift b/Trio/Sources/Modules/AdaptProfile/AdaptProfileProvider.swift index ce54e2e8f..54d2b6497 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/AutoISFHistory/AutoISFHistoryStateModel.swift b/Trio/Sources/Modules/AutoISFHistory/AutoISFHistoryStateModel.swift index f6646e26d..88025be8f 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/CGMSettings/View/CustomCGMOptionsView.swift b/Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift index 8f966e661..bb2e17ae8 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 81f452933..dd8af9090 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/Export/ExportStateModel.swift b/Trio/Sources/Modules/Export/ExportStateModel.swift index b9304eb41..740f3d0d5 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 c79ada1b6..940fec2f0 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/History/View/HistoryRootView.swift b/Trio/Sources/Modules/History/View/HistoryRootView.swift index 189c92587..389c87bf5 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+Setup/DeterminationSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift index 54f4a1ea8..94ed18013 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel+Setup/DeterminationSetup.swift @@ -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) } } diff --git a/Trio/Sources/Modules/Home/HomeStateModel+Setup/ProfileExpirationSetup.swift b/Trio/Sources/Modules/Home/HomeStateModel+Setup/ProfileExpirationSetup.swift index fc99a5cbd..ccc851c09 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.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift index f5cf42c05..e7bb4392a 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -939,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 } @@ -1131,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/ProfileScheduler/ProfileSchedulerProvider.swift b/Trio/Sources/Modules/ProfileScheduler/ProfileSchedulerProvider.swift index 4d02d05a4..8c02e3cc2 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 7d5d73fd6..b67789f79 100644 --- a/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift +++ b/Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift @@ -86,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/Services/BolusCalculator/BolusCalculationManager.swift b/Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift index f9e22f240..116e90071 100644 --- a/Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift +++ b/Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift @@ -379,8 +379,8 @@ 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 diff --git a/Trio/Sources/Services/ProfileScheduleFirer.swift b/Trio/Sources/Services/ProfileScheduleFirer.swift index bd23cd76a..8d602d5be 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/SettingsManager/ActiveProfileMirror.swift b/Trio/Sources/Services/SettingsManager/ActiveProfileMirror.swift index b472f4530..7eb864f3a 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 b4399bf9c..a654beb55 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/UserNotifications/UserNotificationsManager.swift b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift index f938925eb..90e44012e 100644 --- a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift +++ b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift @@ -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 @@ -296,12 +296,12 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate { let profileID = UUID(uuidString: profileRaw), let occurrenceEpoch = info[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( @@ -327,11 +327,11 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate { let profileID = UUID(uuidString: profileRaw), let occurrenceEpoch = info[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 3ef088ff8..fa38e796a 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/GarminCommandRouter.swift b/Trio/Sources/Services/WatchManager/GarminCommandRouter.swift index 4701f79a8..ec7ec350e 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 cf21ce261..9707c2239 100644 --- a/Trio/Sources/Services/WatchManager/GarminManager.swift +++ b/Trio/Sources/Services/WatchManager/GarminManager.swift @@ -1220,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)))") } } } @@ -1232,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) } } diff --git a/Trio/Sources/Shortcuts/TempPresets/CreateCustomTempTargetIntent.swift b/Trio/Sources/Shortcuts/TempPresets/CreateCustomTempTargetIntent.swift index df9382c95..8ccb99c51 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 77108dd19..2bc312895 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 9b22ccdf9..a4c243e19 100644 --- a/Trio/Sources/Shortcuts/TempPresets/TempPresetsIntentRequest.swift +++ b/Trio/Sources/Shortcuts/TempPresets/TempPresetsIntentRequest.swift @@ -116,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( diff --git a/TrioTests/WatchCommandTests/GarminCommandRouterTests.swift b/TrioTests/WatchCommandTests/GarminCommandRouterTests.swift index add878f9b..d24155ee4 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/WatchCommandTestDoubles.swift b/TrioTests/WatchCommandTests/WatchCommandTestDoubles.swift index 786c39a8b..24e41529b 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)) } } } From c5962d0d581cf1d4febeaa37b60e12700d1a159b Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 27 Oct 2026 08:33:46 +0100 Subject: [PATCH 48/48] Fix build errors left by the upstream logging merge - UserNotificationsManager: rename the notification payload binding from `info` to `userInfo` in both schedule-notification handlers so it no longer shadows the global `info(_:_:)` logging function. - TreatmentsStateModel: restore Tai's `minPredBG` source for the simulated determination; the merge brought in upstream's `minPredBGFromReason`, which Tai does not define. - WatchCommandIntegrationTests: pass a level to `LogCapture.record`, which now takes `(LogLevel, String)`. --- .../Treatments/TreatmentsStateModel.swift | 2 +- .../UserNotificationsManager.swift | 16 ++++++++-------- .../WatchCommandIntegrationTests.swift | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift b/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift index d660b3dd7..e8049279d 100644 --- a/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift +++ b/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift @@ -977,7 +977,7 @@ extension Treatments.StateModel { // Update evBG and minPredBG from simulated determination if let simDetermination = simulated { evBG = Decimal(simDetermination.eventualBG ?? 0) - minPredBG = simDetermination.minPredBGFromReason ?? 0 + minPredBG = simDetermination.minPredBG ?? 0 debug(.bolus, "Forecast simulated, min predicted BG \(minPredBG) mg/dL") } } diff --git a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift index 90e44012e..7b4a3e0be 100644 --- a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift +++ b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift @@ -288,13 +288,13 @@ 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 { warning(.alerts, "Profile schedule notification payload is malformed, ignoring it") return @@ -319,13 +319,13 @@ 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 { warning(.alerts, "Profile schedule notification payload is malformed, ignoring tap") return diff --git a/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift b/TrioTests/WatchCommandTests/WatchCommandIntegrationTests.swift index f7c76a55b..d1238ff38 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"])