TelemetryClient.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import Foundation
  2. import LoopKit
  3. import Swinject
  4. import UIKit
  5. // MARK: - TelemetryClient
  6. /// Opt-out anonymous usage check-in. Sends a small JSON payload to a self-hosted
  7. /// endpoint at most once every 24 hours, plus once after a new build is installed.
  8. /// Consent is collected during onboarding (or via a one-time migration sheet for
  9. /// existing users) and editable in Settings → App Diagnostics.
  10. ///
  11. /// No health data, credentials, or personally-identifying information is sent.
  12. /// See `buildPayload()` for the exact set of fields and `TelemetryPreviewView`
  13. /// for the in-app inspector that renders the same payload.
  14. final class TelemetryClient: Injectable {
  15. static let shared = TelemetryClient()
  16. // MARK: Endpoint configuration
  17. // TODO: Replace with the production telemetry endpoint
  18. // and bearer token. While these placeholders remain, `send()` no-ops at
  19. // debug-log level — consent, persistence, scheduling, and the UI work
  20. // unchanged for testing against any mock server.
  21. private static let endpoint: URL? = nil
  22. private static let writeToken = ""
  23. private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60
  24. private static let dailyInterval: TimeInterval = 24 * 60 * 60
  25. private static let retryAfterFailureInterval: TimeInterval = 60
  26. // MARK: Injected services
  27. @Injected() private var apsManager: APSManager!
  28. @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
  29. @Injected() private var settingsManager: SettingsManager!
  30. @Injected() private var tidepoolManager: TidepoolManager!
  31. @Injected() private var healthKitManager: HealthKitManager!
  32. @Injected() private var keychain: Keychain!
  33. private let lock = NSRecursiveLock()
  34. private var didInjectServices = false
  35. private var timer: DispatchTimer?
  36. private init() {}
  37. private func injectIfNeeded() {
  38. lock.lock()
  39. defer { lock.unlock() }
  40. guard !didInjectServices else { return }
  41. injectServices(TrioApp.resolver)
  42. didInjectServices = true
  43. }
  44. // MARK: - Cold launches
  45. /// Records a cold launch in a sliding 7-day window of timestamps. The count
  46. /// of entries in the window ships as `coldLaunches7d` in every ping — a
  47. /// "how often does iOS recycle this process" signal that is directly
  48. /// comparable across pings regardless of the cadence between them.
  49. func recordColdLaunch(now: Date = Date()) {
  50. let cutoff = now.addingTimeInterval(-Self.weeklyInterval)
  51. var recent = PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []
  52. recent.removeAll { $0 < cutoff }
  53. recent.append(now)
  54. PropertyPersistentFlags.shared.telemetryColdLaunchTimes = recent
  55. }
  56. // MARK: - Install identifier
  57. /// Stable per-install UUID, generated lazily on first call. IDFV resets if
  58. /// the user deletes every Trio-team app at once; this survives
  59. /// independently and is wiped only by deleting Trio itself.
  60. private func installId() -> String {
  61. if let existing = PropertyPersistentFlags.shared.telemetryInstallId, !existing.isEmpty {
  62. return existing
  63. }
  64. let new = UUID().uuidString
  65. PropertyPersistentFlags.shared.telemetryInstallId = new
  66. return new
  67. }
  68. // MARK: - Cadence
  69. /// True when the running build's commit SHA differs from the SHA recorded
  70. /// at the last successful send. Used at startup to fire one immediate ping
  71. /// after an app update — the 24h scheduler can't notice a build change and
  72. /// would otherwise wait out the previous interval.
  73. func buildShaChangedSinceLastSend() -> Bool {
  74. let currentSha = BuildDetails.shared.trioCommitSHA
  75. return PropertyPersistentFlags.shared.telemetryLastSentSha != currentSha
  76. }
  77. /// Arms (or re-arms) the 24h send timer. Idempotent. Bails out without
  78. /// scheduling if the user hasn't decided on consent yet or has opted out
  79. /// — there's nothing for the timer to do.
  80. func scheduleRecurring() {
  81. guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
  82. PropertyPersistentFlags.shared.telemetryEnabled == true
  83. else {
  84. return
  85. }
  86. lock.lock()
  87. defer { lock.unlock() }
  88. if timer == nil {
  89. let t = DispatchTimer(timeInterval: Self.dailyInterval)
  90. t.eventHandler = { [weak self] in
  91. Task.detached { await self?.maybeSend() }
  92. }
  93. t.resume()
  94. timer = t
  95. }
  96. }
  97. /// Single entry point for all sends (scheduler tick, consent-yes, startup
  98. /// SHA-change). Gated on consent + opt-in. *When* to send is the caller's
  99. /// decision — startup handles the SHA-change shortcut, the timer handles
  100. /// 24h cadence.
  101. func maybeSend() async {
  102. guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
  103. PropertyPersistentFlags.shared.telemetryEnabled == true
  104. else {
  105. return
  106. }
  107. await send()
  108. }
  109. // MARK: - Payload
  110. /// The exact payload that would be POSTed right now. Pure function: shared
  111. /// by `send()` and `TelemetryPreviewView`.
  112. func buildPayload() -> [String: Any] {
  113. injectIfNeeded()
  114. let bd = BuildDetails.shared
  115. let info = Bundle.main.infoDictionary ?? [:]
  116. var payload: [String: Any] = [:]
  117. if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v }
  118. // appDevVersion is Trio's 4-component dev counter (e.g. "0.7.0.14") —
  119. // the most precise build identifier we have. Always emit, even when
  120. // the Info.plist key is missing, so dashboards can rely on the field.
  121. payload["appDevVersion"] = Bundle.main.appDevVersion ?? "unknown"
  122. payload["commitSha"] = bd.trioCommitSHA
  123. payload["branch"] = bd.trioBranch
  124. // Date-only prefix of the build-date string. Keeps the field a
  125. // low-resolution build identifier, not a precise timestamp.
  126. if let raw = bd.buildDateString, raw.count >= 10 {
  127. payload["buildDate"] = String(raw.prefix(10))
  128. }
  129. payload["isTestFlight"] = bd.isTestFlightBuild()
  130. if let idfv = UIDevice.current.identifierForVendor?.uuidString {
  131. payload["idfv"] = idfv
  132. }
  133. payload["installId"] = installId()
  134. payload["device"] = Self.hardwareIdentifier()
  135. payload["platform"] = Self.detectPlatform()
  136. payload["osVersion"] = UIDevice.current.systemVersion
  137. // Pump model — omitted entirely when no pump is paired.
  138. if let pump = apsManager?.pumpManager {
  139. payload["pumpModel"] = pump.localizedTitle
  140. }
  141. // CGM: enum tells us the configured *type*; the live manager (if any)
  142. // tells us the specific model name. Both are useful — `cgmType`
  143. // distinguishes Dexcom-via-Nightscout from Dexcom-via-direct, etc.
  144. let settings = settingsManager?.settings
  145. payload["cgmType"] = settings?.cgm.rawValue ?? CGMType.none.rawValue
  146. if let cgm = fetchGlucoseManager?.cgmManager {
  147. payload["cgmModel"] = cgm.localizedTitle
  148. }
  149. // Nightscout: keys present in keychain ⇒ configured. We never include
  150. // the URL or token themselves.
  151. let nsUrl = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey)?
  152. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  153. let nsSecret = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)?
  154. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  155. payload["nightscoutPaired"] = !nsUrl.isEmpty && !nsSecret.isEmpty
  156. payload["tidepoolPaired"] = tidepoolManager?.getTidepoolServiceUI() != nil
  157. let useHealth = settings?.useAppleHealth ?? false
  158. let healthAuthorized = healthKitManager?.hasGrantedFullWritePermissions ?? false
  159. payload["appleHealthEnabled"] = useHealth && healthAuthorized
  160. if let settings = settings {
  161. payload["closedLoop"] = settings.closedLoop
  162. payload["units"] = settings.units.rawValue
  163. payload["useLiveActivity"] = settings.useLiveActivity
  164. payload["useCalendar"] = settings.useCalendar
  165. }
  166. payload["coldLaunches7d"] = (PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []).count
  167. // Submodule SHAs — small, useful for tracking which LoopKit / OmniBLE /
  168. // etc. revision the user is on. Branch is dropped to keep payload size small.
  169. let submoduleShas = bd.submodules.mapValues { $0.commitSHA }
  170. if !submoduleShas.isEmpty {
  171. payload["submodules"] = submoduleShas
  172. }
  173. return payload
  174. }
  175. // MARK: - Send
  176. /// Build payload, POST it, update last-sent state on 2xx. Fire-and-forget;
  177. /// errors are logged at debug level only and never surfaced to the UI.
  178. func send() async {
  179. guard let endpoint = Self.endpoint else {
  180. debug(.telemetry, "skip send: endpoint not configured (TODO)") // FIXME: adjust debug statement once backend is set up
  181. return
  182. }
  183. let payload = buildPayload()
  184. guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
  185. debug(.telemetry, "skip send: payload not JSON-serializable")
  186. return
  187. }
  188. var request = URLRequest(url: endpoint)
  189. request.httpMethod = "POST"
  190. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  191. if !Self.writeToken.isEmpty {
  192. request.setValue("Bearer \(Self.writeToken)", forHTTPHeaderField: "Authorization")
  193. }
  194. request.httpBody = body
  195. request.timeoutInterval = 15
  196. do {
  197. let (_, response) = try await URLSession.shared.data(for: request)
  198. guard let http = response as? HTTPURLResponse else {
  199. debug(.telemetry, "send: non-HTTP response")
  200. return
  201. }
  202. if (200 ..< 300).contains(http.statusCode) {
  203. PropertyPersistentFlags.shared.telemetryLastSentAt = Date()
  204. PropertyPersistentFlags.shared.telemetryLastSentSha = BuildDetails.shared.trioCommitSHA
  205. debug(.telemetry, "send ok status=\(http.statusCode)")
  206. } else {
  207. debug(.telemetry, "send non-2xx status=\(http.statusCode)")
  208. }
  209. } catch {
  210. debug(.telemetry, "send error: \(error.localizedDescription)")
  211. }
  212. }
  213. // MARK: - Helpers
  214. /// `iPhone15,2`-style identifier from `utsname.machine`. Returns
  215. /// `Simulator <SIMULATOR_MODEL_IDENTIFIER>` on the simulator so analysis
  216. /// can ignore those rows.
  217. static func hardwareIdentifier() -> String {
  218. #if targetEnvironment(simulator)
  219. let env = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "Unknown"
  220. return "Simulator \(env)"
  221. #else
  222. var sys = utsname()
  223. uname(&sys)
  224. let mirror = Mirror(reflecting: sys.machine)
  225. let machine = mirror.children.reduce(into: "") { acc, child in
  226. guard let v = child.value as? Int8, v != 0 else { return }
  227. acc.append(Character(UnicodeScalar(UInt8(v))))
  228. }
  229. return machine.isEmpty ? "Unknown" : machine
  230. #endif
  231. }
  232. static func detectPlatform() -> String {
  233. #if targetEnvironment(macCatalyst)
  234. return "macCatalyst"
  235. #else
  236. switch UIDevice.current.userInterfaceIdiom {
  237. case .pad: return "iPadOS"
  238. default: return "iOS"
  239. }
  240. #endif
  241. }
  242. }