diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index e08c6d3a3d..b31985d9a1 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -184,6 +184,19 @@ public final class AlertManager { func loopDidComplete(_ lastLoopDate: Date? = nil) async { // use now if there is no lastLoopDate await rescheduleLoopNotRunningNotifications(lastLoopDate ?? Date()) + await reportDisplayedScheduledAlerts() + } + + private func reportDisplayedScheduledAlerts() async { + let now = getCurrentDate() + let start = UserDefaults.standard.scheduledAlertsReportedThrough ?? now + UserDefaults.standard.scheduledAlertsReportedThrough = now + guard start < now, let alerts = try? await alertStore.lookupScheduledAlertsDue(after: start, through: now) else { + return + } + for alert in alerts { + analyticsServicesManager.didDisplayAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel, scheduled: true) + } } private func rescheduleLoopNotRunningNotifications() { @@ -271,6 +284,7 @@ public final class AlertManager { let interruptionLevel: Alert.InterruptionLevel = notification.isCritical ? .critical : .timeSensitive let alert = Alert(identifier: alertIdentifier, foregroundContent: nil, backgroundContent: content, trigger: .immediate, interruptionLevel: interruptionLevel) await recordIssued(alert: alert, at: notification.alertAt) + analyticsServicesManager.didDisplayAlert(identifier: alertIdentifier.value, interruptionLevel: interruptionLevel, scheduled: true) } else { stillPendingNotifications.append(notification) } @@ -384,7 +398,9 @@ extension AlertManager: AlertIssuer { deferredAlerts.append(alert) return } - analyticsServicesManager.didIssueAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel) + if alert.trigger == .immediate { + analyticsServicesManager.didDisplayAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel, scheduled: false) + } scheduleAlertWithSchedulers(alert) await alertStore.recordIssued(alert: alert) } diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index 1e5cd8fa51..11d4495c0d 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -197,6 +197,38 @@ public class AlertStore { } } + /// Delayed and repeating alerts that came due in (start, end] without being retracted first. + /// Only the latest issue of each identifier counts, since re-issuing replaces the scheduled alert. + public func lookupScheduledAlertsDue(after start: Date, through end: Date) async throws -> [(identifier: Alert.Identifier, interruptionLevel: Alert.InterruptionLevel)] { + try await managedObjectContext.perform { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "triggerType != %d", Alert.Trigger.immediate.storedType), + NSPredicate(format: "issuedDate > %@ AND issuedDate <= %@", end.addingTimeInterval(-.days(30)) as NSDate, end as NSDate) + ]) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "issuedDate", ascending: true) ] + + var latest: [Alert.Identifier: StoredAlert] = [:] + for alert in try self.managedObjectContext.fetch(fetchRequest) { + latest[alert.identifier] = alert + } + + return latest.values.compactMap { alert in + guard let interval = alert.triggerInterval?.doubleValue else { + return nil + } + let due = alert.issuedDate.addingTimeInterval(interval) + guard due > start, due <= end else { + return nil + } + if let retractedDate = alert.retractedDate, retractedDate <= due { + return nil + } + return (alert.identifier, alert.interruptionLevel) + } + } + } + public func lookupAllAcknowledgedUnretractedRepeatingAlerts() async throws -> [StoredAlert] { try await managedObjectContext.perform { let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 7fb76247aa..3a68ee2e92 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -17,6 +17,10 @@ final class AnalyticsServicesManager { private var analyticsServices = [AnalyticsService]() + var stateProvider: (@MainActor () -> AnalyticsState)? + + private var lastState: AnalyticsState? + init() {} func addService(_ analyticsService: AnalyticsService) { @@ -139,10 +143,6 @@ final class AnalyticsServicesManager { logEvent("Minimum BG Guard change") } - if newValue.dosingEnabled != oldValue.dosingEnabled { - logEvent("Closed loop enabled change") - } - if newValue.basalRateSchedule?.timeZone != oldValue.basalRateSchedule?.timeZone { logEvent("Therapy schedule time zone change") } @@ -156,6 +156,7 @@ final class AnalyticsServicesManager { func pumpWasRemoved() { logEvent("Pump Removed") + identifyPumpType("None") } func pumpWasAdded(identifier: String) { @@ -164,72 +165,117 @@ final class AnalyticsServicesManager { func cgmWasRemoved() { logEvent("CGM Removed") + identifyCGMType("None") } func cgmWasAdded(identifier: String) { logEvent("CGM Added", withProperties: ["identifier" : identifier]) } - func didAddCarbs(source: String, amount: Double, isFavoriteFood: Bool = false, inSession: Bool = false) { - logEvent("Carb entry created", withProperties: ["source" : source, "amount": "\(amount)", "isFavoriteFood": isFavoriteFood], outOfSession: inSession) + func didAddCarbs(source: String, isFavoriteFood: Bool = false) { + logEvent("Carb entry created", withProperties: ["source" : source, "isFavoriteFood": isFavoriteFood], outOfSession: source != "Phone") } func didRetryBolus() { logEvent("Bolus Retry") } - func didBolus(source: String, units: Double, inSession: Bool = false) { - logEvent("Bolus set", withProperties: ["source" : source, "units": "\(units)"], outOfSession: true) + func didBolus(source: String) { + logEvent("Bolus set", withProperties: ["source" : source], outOfSession: source != "Phone") } + // MARK: - Daily Summary + + @MainActor func didFetchNewCGMData() { - logEvent("CGM Fetch", outOfSession: true) + updateDailySummary { $0.cgmFetchCount += 1 } } + @MainActor func loopDidSucceed(_ duration: TimeInterval) { - logEvent("Loop success", withProperties: ["duration": duration], outOfSession: true) + updateDailySummary { summary in + summary.loopSuccessCount += 1 + summary.totalLoopDuration += duration + summary.maxLoopDuration = max(summary.maxLoopDuration, duration) + } } + @MainActor func loopDidError(error: LoopError) { - var props = [AnyHashable: Any]() + updateDailySummary { $0.loopErrorCounts[error.issueId, default: 0] += 1 } + } - props["issueId"] = error.issueId + @MainActor + func identifyState() { + guard let state = stateProvider?() else { + return + } + identify("Closed Loop", value: state.closedLoop ? "On" : "Off") + identify("Dosing Strategy", value: state.dosingStrategy) + identify("Integral Retrospective Correction", value: state.integralRetrospectiveCorrection ? "On" : "Off") + identify("Glucose Based Partial Application", value: state.glucoseBasedPartialApplication ? "On" : "Off") + lastState = state + } - for (detailKey, detail) in error.issueDetails { - props[detailKey] = detail + @MainActor + private func updateDailySummary(_ update: (inout AnalyticsDailySummary) -> Void, now: Date = Date()) { + let day = Calendar.current.startOfDay(for: now) + var summary = UserDefaults.standard.analyticsDailySummary ?? AnalyticsDailySummary(day: day) + + if summary.day != day { + logEvent("Daily summary", withProperties: summary.properties, outOfSession: true) + summary = AnalyticsDailySummary(day: day) } - logEvent("Loop error", withProperties: props, outOfSession: true) + update(&summary) + + if let state = stateProvider?() { + summary.state = state + if let lastState, state != lastState { + logStateChanges(from: lastState, to: state) + identifyState() + } + } + + UserDefaults.standard.analyticsDailySummary = summary } - func didIssueAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel) { - logEvent("Alert Issued", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue]) + private func logStateChanges(from oldState: AnalyticsState, to newState: AnalyticsState) { + if newState.closedLoop != oldState.closedLoop { + logEvent("Closed loop enabled change", withProperties: ["enabled": newState.closedLoop]) + } + if newState.dosingStrategy != oldState.dosingStrategy { + logEvent("Dosing strategy change", withProperties: ["value": newState.dosingStrategy]) + } + if newState.integralRetrospectiveCorrection != oldState.integralRetrospectiveCorrection { + logEvent("Algorithm experiment change", withProperties: ["experiment": "Integral Retrospective Correction", "enabled": newState.integralRetrospectiveCorrection]) + } + if newState.glucoseBasedPartialApplication != oldState.glucoseBasedPartialApplication { + logEvent("Algorithm experiment change", withProperties: ["experiment": "Glucose Based Partial Application", "enabled": newState.glucoseBasedPartialApplication]) + } } - func didEnactOverride(name: String, symbol: String, duration: TemporaryScheduleOverride.Duration, insulinSensitivityMultiplier: Double = 1.0, targetRange: ClosedRange? = nil) - { - let combinedName = "\(symbol) - \(name)" + func didDisplayAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel, scheduled: Bool) { + logEvent("Alert Displayed", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue, "scheduled": scheduled], outOfSession: scheduled) + } - var properties: [String: Any] = [ - "name": name, - "symbol": symbol, - "sensitivityMultiplier": insulinSensitivityMultiplier, - "nameWithEmoji": combinedName - ] + func didEnactOverride(kind: String, duration: TemporaryScheduleOverride.Duration, activityType: String? = nil) { + var properties: [String: Any] = ["kind": kind] - if let targetUpperBound = targetRange?.upperBound.doubleValue(for: LoopUnit.milligramsPerDeciliter) { - properties["targetUpperBound"] = targetUpperBound - } - if let targetLowerBound = targetRange?.lowerBound.doubleValue(for: LoopUnit.milligramsPerDeciliter) { - properties["targetLowerBound"] = targetLowerBound + switch duration { + case .finite(let interval): + properties["durationMinutes"] = Int(interval.minutes) + case .indefinite: + properties["durationMinutes"] = "indefinite" } + properties["activityType"] = activityType logEvent("Override Enacted", withProperties: properties) } - func didCancelOverride(name: String) { - logEvent("Override Canceled", withProperties: ["name": name]) + func didEndOverride(kind: String) { + logEvent("Override Ended", withProperties: ["kind": kind]) } } @@ -238,30 +284,84 @@ final class AnalyticsServicesManager { extension AnalyticsServicesManager: PresetActivationObserver { func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) { switch context { - case .preMeal: - didEnactOverride(name: "preMeal", symbol: "", duration: duration) - case .custom: - didEnactOverride(name: "custom", symbol: "", duration: duration) - case .preset(let preset): - didEnactOverride( - name: preset.name, - symbol: preset.symbol?.textualRepresentation ?? "", - duration: duration, - insulinSensitivityMultiplier: preset.settings.effectiveInsulinNeedsScaleFactor, - targetRange: preset.settings.targetRange - ) case .activity(let activity): - didEnactOverride( - name: activity.activityType.name, - symbol: activity.preset.symbol?.textualRepresentation ?? "", - duration: activity.preset.duration, - insulinSensitivityMultiplier: activity.preset.settings.effectiveInsulinNeedsScaleFactor, - targetRange: activity.preset.settings.targetRange - ) + didEnactOverride(kind: context.analyticsKind, duration: activity.preset.duration, activityType: activity.activityType.rawValue) + default: + didEnactOverride(kind: context.analyticsKind, duration: duration) } } - func presetDeactivated(context: TemporaryScheduleOverride.Context) {} + func presetDeactivated(context: TemporaryScheduleOverride.Context) { + didEndOverride(kind: context.analyticsKind) + } +} + +extension TemporaryScheduleOverride.Context { + var analyticsKind: String { + switch self { + case .preMeal: return "preMeal" + case .custom: return "custom" + case .preset: return "preset" + case .activity: return "activity" + } + } +} + +struct AnalyticsState: Codable, Equatable { + var closedLoop: Bool + var dosingStrategy: String + var integralRetrospectiveCorrection: Bool + var glucoseBasedPartialApplication: Bool +} + +struct AnalyticsDailySummary: Codable, Equatable { + var day: Date + var utcOffset = TimeZone.current.secondsFromGMT() + var loopSuccessCount = 0 + var totalLoopDuration: TimeInterval = 0 + var maxLoopDuration: TimeInterval = 0 + var loopErrorCounts: [String: Int] = [:] + var cgmFetchCount = 0 + var state: AnalyticsState? + + var properties: [AnyHashable: Any] { + var properties: [AnyHashable: Any] = [ + "day": ISO8601DateFormatter.string(from: day, timeZone: TimeZone(secondsFromGMT: utcOffset) ?? .current, formatOptions: .withFullDate), + "utcOffset": utcOffset, + "loopSuccessCount": loopSuccessCount, + "meanLoopDuration": loopSuccessCount > 0 ? totalLoopDuration / Double(loopSuccessCount) : 0, + "maxLoopDuration": maxLoopDuration, + "loopErrorCount": loopErrorCounts.values.reduce(0, +), + "loopErrorCounts": loopErrorCounts, + "cgmFetchCount": cgmFetchCount + ] + if let state { + properties["closedLoop"] = state.closedLoop + properties["dosingStrategy"] = state.dosingStrategy + properties["integralRetrospectiveCorrection"] = state.integralRetrospectiveCorrection + properties["glucoseBasedPartialApplication"] = state.glucoseBasedPartialApplication + } + return properties + } +} + +extension UserDefaults { + private static let analyticsDailySummaryKey = "com.loopkit.Loop.AnalyticsDailySummary" + private static let scheduledAlertsReportedThroughKey = "com.loopkit.Loop.AnalyticsScheduledAlertsReportedThrough" + + var scheduledAlertsReportedThrough: Date? { + get { object(forKey: Self.scheduledAlertsReportedThroughKey) as? Date } + set { set(newValue, forKey: Self.scheduledAlertsReportedThroughKey) } + } + + var analyticsDailySummary: AnalyticsDailySummary? { + get { + data(forKey: Self.analyticsDailySummaryKey).flatMap { try? JSONDecoder().decode(AnalyticsDailySummary.self, from: $0) } + } + set { + set(newValue.flatMap { try? JSONEncoder().encode($0) }, forKey: Self.analyticsDailySummaryKey) + } + } } extension AutomaticDosingStrategy { diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index a4bb304c25..d775a298af 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -483,7 +483,21 @@ class LoopAppManager: NSObject { analyticsServicesManager.identifyWorkspaceGitRevision(workspaceGitRevision) } - analyticsServicesManager.identify("Dosing Strategy", value: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue) + analyticsServicesManager.stateProvider = { [settingsManager = settingsManager!] in + AnalyticsState( + closedLoop: settingsManager.dosingEnabled, + dosingStrategy: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue, + integralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, + glucoseBasedPartialApplication: UserDefaults.standard.glucoseBasedApplicationFactorEnabled + ) + } + analyticsServicesManager.identifyState() + if let pumpManager = deviceDataManager.pumpManager { + analyticsServicesManager.identifyPumpType(pumpManager.pluginIdentifier) + } + if let cgmManager = deviceDataManager.cgmManager { + analyticsServicesManager.identifyCGMType(cgmManager.pluginIdentifier) + } let serviceNames = servicesManager.activeServices.map { $0.pluginIdentifier } analyticsServicesManager.identify("Services", array: serviceNames) diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index c7252128fa..b557f1840a 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -329,7 +329,7 @@ extension ServicesManager: ServiceDelegate { try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) await remoteDataServicesManager.performUpload(for: .carb) - analyticsServicesManager.didAddCarbs(source: "Remote", amount: amountInGrams) + analyticsServicesManager.didAddCarbs(source: "Remote") } catch { NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) throw error @@ -354,7 +354,7 @@ extension ServicesManager: ServiceDelegate { try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId) NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) await remoteDataServicesManager.performUpload(for: .dose) - analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) + analyticsServicesManager.didBolus(source: "Remote") } catch { NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) throw error diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index 3ccaac5c4a..466581b765 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -235,6 +235,8 @@ class SettingsManager { analyticsServicesManager?.didChangeInsulinModel() } + analyticsServicesManager?.didChangeLoopSettings(from: oldValue, to: newValue) + if newValue.dosingEnabled != oldValue.dosingEnabled { self.dosingEnabled = newValue.dosingEnabled } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 182520acde..92a9557101 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -436,7 +436,7 @@ final class WatchDataManager: NSObject { if let carbEntry = bolus.carbEntry { let storedCarbEntry = try await loopDataManager.addCarbEntry(carbEntry) dosingDecision.carbEntry = storedCarbEntry - self.analyticsServicesManager?.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram)) + self.analyticsServicesManager?.didAddCarbs(source: "Watch") } else { dosingDecision.carbEntry = nil } @@ -449,7 +449,7 @@ final class WatchDataManager: NSObject { } try await deviceManager.enactBolus(units: bolus.value, decisionId: dosingDecision.id, activationType: bolus.activationType) - self.analyticsServicesManager?.didBolus(source: "Watch", units: bolus.value) + self.analyticsServicesManager?.didBolus(source: "Watch") } func handleWatchMessage(_ message: [String: Any]) async throws -> [String: Any] { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 7b699618e7..b1289a714d 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1634,6 +1634,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } func presentSettings() { + analyticsServicesManager?.didDisplaySettingsScreen() let hostingController = DismissibleHostingController( rootView: SettingsView(viewModel: statusTableViewModel.settingsViewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 6a10939cad..b3b24dbc67 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -388,7 +388,7 @@ final class BolusEntryViewModel: ObservableObject { } if let storedCarbEntry = await saveCarbEntry(carbEntry, replacingEntry: originalCarbEntry) { self.dosingDecision.carbEntry = storedCarbEntry - self.analyticsServicesManager?.didAddCarbs(source: "Phone", amount: storedCarbEntry.quantity.doubleValue(for: .gram), isFavoriteFood: storedCarbEntry.favoriteFoodID != nil) + self.analyticsServicesManager?.didAddCarbs(source: "Phone", isFavoriteFood: storedCarbEntry.favoriteFoodID != nil) } else { self.presentAlert(.carbEntryPersistenceFailure) return false @@ -407,7 +407,7 @@ final class BolusEntryViewModel: ObservableObject { } catch { log.error("Failed to enact bolus: %{public}@", String(describing: error)) } - self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) + self.analyticsServicesManager?.didBolus(source: "Phone") } return true } diff --git a/LoopTests/Managers/Alerts/AlertStoreTests.swift b/LoopTests/Managers/Alerts/AlertStoreTests.swift index 5f56baf124..8378bb5925 100644 --- a/LoopTests/Managers/Alerts/AlertStoreTests.swift +++ b/LoopTests/Managers/Alerts/AlertStoreTests.swift @@ -91,6 +91,25 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(999, newAnchor?.modificationCounter) } + func testLookupScheduledAlertsDue() async throws { + let issued = Self.historicDate + let due = issued.addingTimeInterval(Self.delayedAlertDelay) + await alertStore.recordIssued(alert: alert1, at: issued) + await alertStore.recordIssued(alert: delayedAlert, at: issued) + await alertStore.recordIssued(alert: repeatingAlert, at: issued) + try await alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: due.addingTimeInterval(-1)) + + var dueAlerts = try await alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(1)) + XCTAssertEqual([Self.delayedAlertIdentifier], dueAlerts.map { $0.identifier }) + + dueAlerts = try await alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(-1)) + XCTAssertTrue(dueAlerts.isEmpty) + + await alertStore.recordIssued(alert: delayedAlert, at: issued.addingTimeInterval(10)) + dueAlerts = try await alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(1)) + XCTAssertTrue(dueAlerts.isEmpty) + } + func testRecordIssued() async throws { await alertStore.recordIssued(alert: alert1, at: Self.historicDate) let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1)