Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion Loop/Managers/Alerts/AlertManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
32 changes: 32 additions & 0 deletions Loop/Managers/Alerts/AlertStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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> = 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> = StoredAlert.fetchRequest()
Expand Down
206 changes: 153 additions & 53 deletions Loop/Managers/AnalyticsServicesManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ final class AnalyticsServicesManager {

private var analyticsServices = [AnalyticsService]()

var stateProvider: (@MainActor () -> AnalyticsState)?

private var lastState: AnalyticsState?

init() {}

func addService(_ analyticsService: AnalyticsService) {
Expand Down Expand Up @@ -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")
}
Expand All @@ -156,6 +156,7 @@ final class AnalyticsServicesManager {

func pumpWasRemoved() {
logEvent("Pump Removed")
identifyPumpType("None")
}

func pumpWasAdded(identifier: String) {
Expand All @@ -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<LoopQuantity>? = 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])
}
}

Expand All @@ -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 {
Expand Down
16 changes: 15 additions & 1 deletion Loop/Managers/LoopAppManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading