TelemetryClient.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. import Foundation
  2. import HealthKit
  3. import LoopKit
  4. import Swinject
  5. import UIKit
  6. // MARK: - TelemetryClient
  7. /// Opt-out anonymous usage check-in. Sends a small JSON payload to a self-hosted
  8. /// endpoint at most once every 24 hours, plus once after a new build is installed.
  9. /// Consent is collected during onboarding (or via a one-time migration sheet for
  10. /// existing users) and editable in Settings → App Diagnostics.
  11. ///
  12. /// No health data, credentials, or personally-identifying information is sent.
  13. /// See `buildPayload()` for the exact set of fields and `TelemetryPreviewView`
  14. /// for the in-app inspector that renders the same payload.
  15. final class TelemetryClient: Injectable {
  16. static let shared = TelemetryClient()
  17. // MARK: Endpoint configuration
  18. private static let productionBaseURL: URL? = URL(string: "https://telemetry.triodocs.org")
  19. // MARK: if you fork Trio and keep telemetry enabled, please change the name here
  20. // so that we can distinguish forks from mainline Trio builds in our telemetry.
  21. private static let telemetryAppName: String = "Trio"
  22. /// Effective base URL: respects the debug override in
  23. /// `PropertyPersistentFlags.telemetryDebugServerURL`, then falls back to
  24. /// `productionBaseURL`. Used by both the registration and `/checkin` paths.
  25. private static var baseURL: URL? {
  26. if let override = PropertyPersistentFlags.shared.telemetryDebugServerURL?
  27. .trimmingCharacters(in: .whitespacesAndNewlines),
  28. !override.isEmpty,
  29. let url = URL(string: override)
  30. {
  31. return url
  32. }
  33. return productionBaseURL
  34. }
  35. private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60
  36. private static let dailyInterval: TimeInterval = 24 * 60 * 60
  37. private static let maxPayloadBytes = 4096
  38. private static let buildDateFormatter: DateFormatter = {
  39. let f = DateFormatter()
  40. f.dateFormat = "yyyy-MM-dd"
  41. f.locale = Locale(identifier: "en_US_POSIX")
  42. f.timeZone = TimeZone(identifier: "UTC")
  43. return f
  44. }()
  45. // MARK: Injected services
  46. @Injected() private var apsManager: APSManager!
  47. @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
  48. @Injected() private var settingsManager: SettingsManager!
  49. @Injected() private var tidepoolManager: TidepoolManager!
  50. @Injected() private var healthKitManager: HealthKitManager!
  51. @Injected() private var keychain: Keychain!
  52. private let lock = NSRecursiveLock()
  53. private var didInjectServices = false
  54. private var timer: DispatchTimer?
  55. private init() {}
  56. private func injectIfNeeded() {
  57. lock.lock()
  58. defer { lock.unlock() }
  59. guard !didInjectServices else { return }
  60. injectServices(TrioApp.resolver)
  61. didInjectServices = true
  62. }
  63. // MARK: - Cold launches
  64. /// Records a cold launch in a sliding 7-day window of timestamps. The count
  65. /// of entries in the window ships as `coldLaunches7d` in every ping — a
  66. /// "how often does iOS recycle this process" signal that is directly
  67. /// comparable across pings regardless of the cadence between them.
  68. func recordColdLaunch(now: Date = Date()) {
  69. let cutoff = now.addingTimeInterval(-Self.weeklyInterval)
  70. var recent = PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []
  71. recent.removeAll { $0 < cutoff }
  72. recent.append(now)
  73. PropertyPersistentFlags.shared.telemetryColdLaunchTimes = recent
  74. }
  75. // MARK: - Install identifier
  76. /// Stable per-install UUID, generated lazily on first call. IDFV resets if
  77. /// the user deletes every Trio-team app at once; this survives
  78. /// independently and is wiped only by deleting Trio itself.
  79. private func installId() -> String {
  80. if let existing = PropertyPersistentFlags.shared.telemetryInstallId, !existing.isEmpty {
  81. return existing
  82. }
  83. let new = UUID().uuidString
  84. PropertyPersistentFlags.shared.telemetryInstallId = new
  85. return new
  86. }
  87. // MARK: - Cadence
  88. /// True when the running build's commit SHA differs from the SHA recorded
  89. /// at the last successful send. Used at startup to fire one immediate ping
  90. /// after an app update — the 24h scheduler can't notice a build change and
  91. /// would otherwise wait out the previous interval.
  92. func buildShaChangedSinceLastSend() -> Bool {
  93. let currentSha = BuildDetails.shared.trioCommitSHA
  94. return PropertyPersistentFlags.shared.telemetryLastSentSha != currentSha
  95. }
  96. /// Arms (or re-arms) the 24h send timer. Idempotent. Bails out without
  97. /// scheduling if the user hasn't decided on consent yet or has opted out
  98. /// — there's nothing for the timer to do.
  99. ///
  100. /// Best-effort fallback only. GCD timers don't advance while the app is
  101. /// suspended, so on iOS this effectively means "fires only if the app
  102. /// stays foregrounded for 24h." The reliable cadence driver is
  103. /// `checkAndSendIfOverdue()` called on every foreground transition and
  104. /// cold launch.
  105. func scheduleRecurring() {
  106. guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
  107. PropertyPersistentFlags.shared.telemetryEnabled == true
  108. else {
  109. return
  110. }
  111. lock.lock()
  112. defer { lock.unlock() }
  113. if timer == nil {
  114. let t = DispatchTimer(timeInterval: Self.dailyInterval)
  115. t.eventHandler = { [weak self] in
  116. Task.detached { await self?.maybeSend() }
  117. }
  118. t.resume()
  119. timer = t
  120. }
  121. }
  122. /// If consent is set and we haven't successfully sent within the last 24h
  123. /// (or have never sent), fire a send. Called on foreground transitions
  124. /// and from the cold-launch path so daily cadence is kept.
  125. ///
  126. /// Mirrors the pattern used by LoopFollow's `TaskScheduler.checkTasksNow()`:
  127. /// wall-clock comparison against `telemetryLastSentAt`, fire-and-forget
  128. /// if overdue. Safe to call repeatedly — if a send already fired within
  129. /// the window, this is a no-op.
  130. func checkAndSendIfOverdue() {
  131. guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
  132. PropertyPersistentFlags.shared.telemetryEnabled == true
  133. else {
  134. return
  135. }
  136. let lastSent = PropertyPersistentFlags.shared.telemetryLastSentAt
  137. let overdue: Bool = {
  138. guard let lastSent else { return true }
  139. return Date().timeIntervalSince(lastSent) >= Self.dailyInterval
  140. }()
  141. guard overdue else { return }
  142. Task.detached { await self.maybeSend() }
  143. }
  144. /// Single entry point for all sends (scheduler tick, consent-yes, startup
  145. /// SHA-change). Gated on consent + opt-in. *When* to send is the caller's
  146. /// decision — startup handles the SHA-change shortcut, the timer handles
  147. /// 24h cadence.
  148. func maybeSend() async {
  149. guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
  150. PropertyPersistentFlags.shared.telemetryEnabled == true
  151. else {
  152. return
  153. }
  154. await send()
  155. }
  156. // MARK: - Payload
  157. /// The exact payload that would be POSTed right now. Pure function: shared
  158. /// by `send()` and `TelemetryPreviewView`.
  159. func buildPayload() -> [String: Any] {
  160. injectIfNeeded()
  161. let bd = BuildDetails.shared
  162. let info = Bundle.main.infoDictionary ?? [:]
  163. var payload: [String: Any] = [:]
  164. if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v }
  165. payload["appName"] = TelemetryClient.telemetryAppName
  166. // appDevVersion is Trio's 4-component dev counter (e.g. "0.7.0.14") —
  167. // the most precise build identifier we have. Always emit, even when
  168. // the Info.plist key is missing, so dashboards can rely on the field.
  169. payload["appDevVersion"] = Bundle.main.appDevVersion ?? "unknown"
  170. payload["commitSha"] = bd.trioCommitSHA
  171. payload["branch"] = bd.trioBranch
  172. // Date-only (yyyy-MM-dd, UTC) build identifier, parsed from the
  173. // "Tue May 26 12:34:56 UTC 2025" form added in BuildDetails.plist.
  174. if let date = bd.buildDate() {
  175. payload["buildDate"] = Self.buildDateFormatter.string(from: date)
  176. }
  177. payload["isTestFlight"] = bd.isTestFlightBuild()
  178. if let idfv = UIDevice.current.identifierForVendor?.uuidString {
  179. payload["idfv"] = idfv
  180. }
  181. payload["installId"] = installId()
  182. payload["device"] = Self.hardwareIdentifier()
  183. payload["platform"] = Self.detectPlatform()
  184. payload["osVersion"] = UIDevice.current.systemVersion
  185. payload["locale"] = Locale.current.identifier
  186. payload["timeZone"] = TimeZone.current.identifier
  187. // Pump model — omitted entirely when no pump is paired.
  188. if let pump = apsManager?.pumpManager {
  189. payload["pumpModel"] = pump.localizedTitle
  190. }
  191. // CGM: enum tells us the configured *type*; the live manager (if any)
  192. // tells us the specific model name. Both are useful — `cgmType`
  193. // distinguishes Dexcom-via-Nightscout from Dexcom-via-direct, etc.
  194. let settings = settingsManager?.settings
  195. payload["cgmType"] = settings?.cgm.rawValue ?? CGMType.none.rawValue
  196. if let cgm = fetchGlucoseManager?.cgmManager {
  197. payload["cgmModel"] = cgm.localizedTitle
  198. }
  199. // Nightscout: keys present in keychain ⇒ configured. We never include
  200. // the URL or token themselves.
  201. let nsUrl = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey)?
  202. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  203. let nsSecret = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)?
  204. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  205. payload["nightscoutPaired"] = !nsUrl.isEmpty && !nsSecret.isEmpty
  206. payload["tidepoolPaired"] = tidepoolManager?.getTidepoolServiceUI() != nil
  207. // Apple Health: report `enabled = true` as soon as *any* per-type write
  208. // permission is granted, with the full per-type breakdown in
  209. // `appleHealthWrites`.
  210. let appleHealthSampleTypes: [(name: String, type: HKObjectType?)] = [
  211. ("glucose", AppleHealthConfig.healthBGObject),
  212. ("insulin", AppleHealthConfig.healthInsulinObject),
  213. ("carbs", AppleHealthConfig.healthCarbObject),
  214. ("fat", AppleHealthConfig.healthFatObject),
  215. ("protein", AppleHealthConfig.healthProteinObject)
  216. ]
  217. var writePermissions: [String: Bool] = [:]
  218. for (name, type) in appleHealthSampleTypes {
  219. let granted = type.flatMap { healthKitManager?.checkWriteToHealthPermissions(objectTypeToHealthStore: $0) } ?? false
  220. writePermissions[name] = granted
  221. }
  222. payload["appleHealthEnabled"] = writePermissions.values.contains(true)
  223. if !writePermissions.isEmpty {
  224. payload["appleHealthWrites"] = writePermissions
  225. }
  226. if let settings = settings {
  227. payload["closedLoop"] = settings.closedLoop
  228. payload["units"] = settings.units.rawValue
  229. payload["useLiveActivity"] = settings.useLiveActivity
  230. payload["useCalendar"] = settings.useCalendar
  231. }
  232. payload["coldLaunches7d"] = (PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []).count
  233. // Submodule SHAs — small, useful for tracking which LoopKit / OmniBLE /
  234. // etc. revision the user is on. Branch is dropped to keep payload size small.
  235. let submoduleShas = bd.submodules.mapValues { $0.commitSHA }
  236. if !submoduleShas.isEmpty {
  237. payload["submodules"] = submoduleShas
  238. }
  239. return payload
  240. }
  241. // MARK: - Send
  242. /// Build payload, attest it via App Attest, POST it, update last-sent state
  243. /// on 2xx. Fire-and-forget; errors are logged at debug level only.
  244. ///
  245. /// Flow:
  246. /// 1. Skip if `TelemetryAttestor.isSupported == false` (simulator, older
  247. /// devices). This is the primary opt-out for unsupported hardware —
  248. /// sending without attestation would just bounce off the server.
  249. /// 2. Skip if the install has been flagged forbidden by a previous 403.
  250. /// 3. Register if needed (idempotent; first launch + once on retry after
  251. /// transient failures).
  252. /// 4. Serialize the payload. Reject if > 4096 bytes (server-enforced cap).
  253. /// 5. Ask the attestor for an assertion over `SHA256(payload || challenge)`.
  254. /// 6. POST `/checkin` with the three App Attest headers.
  255. ///
  256. /// Backoff: failures don't update `telemetryLastSentAt`, so the next
  257. /// scheduler tick / cold launch retries naturally. The 24h cadence is the
  258. /// natural backoff floor; no per-attempt exponential timer is added.
  259. func send() async {
  260. guard let baseURL = Self.baseURL else {
  261. debug(.telemetry, "skip send: server URL not configured")
  262. return
  263. }
  264. let attestor = TelemetryAttestor.shared
  265. guard attestor.isSupported else {
  266. debug(.telemetry, "skip send: App Attest unsupported (simulator or older device)")
  267. return
  268. }
  269. guard !attestor.isForbidden else {
  270. debug(.telemetry, "skip send: app_id previously rejected (403)")
  271. return
  272. }
  273. do {
  274. try await attestor.registerIfNeeded(baseURL: baseURL)
  275. } catch TelemetryAttestor.AttestError.forbidden {
  276. // Already logged + sticky-flagged in registerIfNeeded.
  277. return
  278. } catch {
  279. debug(.telemetry, "register failed: \(error) — will retry next cycle")
  280. return
  281. }
  282. let payload = buildPayload()
  283. guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
  284. debug(.telemetry, "skip send: payload not JSON-serializable")
  285. return
  286. }
  287. guard body.count <= Self.maxPayloadBytes else {
  288. debug(.telemetry, "skip send: payload exceeds \(Self.maxPayloadBytes) bytes (\(body.count))")
  289. return
  290. }
  291. let assertion: (assertion: String, keyID: String, challenge: String)
  292. do {
  293. assertion = try await attestor.assertion(forPayload: body, baseURL: baseURL)
  294. } catch {
  295. debug(.telemetry, "assertion failed: \(error)")
  296. return
  297. }
  298. var request = URLRequest(url: baseURL.appendingPathComponent("checkin"))
  299. request.httpMethod = "POST"
  300. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  301. request.setValue(assertion.keyID, forHTTPHeaderField: "X-AppAttest-KeyId")
  302. request.setValue(assertion.assertion, forHTTPHeaderField: "X-AppAttest-Assertion")
  303. request.setValue(assertion.challenge, forHTTPHeaderField: "X-Challenge")
  304. request.httpBody = body
  305. request.timeoutInterval = 15
  306. do {
  307. let (_, response) = try await URLSession.shared.data(for: request)
  308. guard let http = response as? HTTPURLResponse else {
  309. debug(.telemetry, "send: non-HTTP response")
  310. return
  311. }
  312. switch http.statusCode {
  313. case 200 ..< 300:
  314. PropertyPersistentFlags.shared.telemetryLastSentAt = Date()
  315. PropertyPersistentFlags.shared.telemetryLastSentSha = BuildDetails.shared.trioCommitSHA
  316. debug(.telemetry, "send ok status=\(http.statusCode)")
  317. case 401:
  318. // Server doesn't recognize our registration (e.g. its registry
  319. // was wiped). Drop the local keyID + registered flag so the
  320. // next cycle generates a fresh key and re-attests — `attestKey`
  321. // can't be re-run on the existing keyID (one-shot per Apple).
  322. attestor.invalidateRegistration()
  323. debug(.telemetry, "send 401: stale registration, will re-register next cycle")
  324. default:
  325. debug(.telemetry, "send non-2xx status=\(http.statusCode)")
  326. }
  327. } catch {
  328. debug(.telemetry, "send error: \(error.localizedDescription)")
  329. }
  330. }
  331. // MARK: - Helpers
  332. /// `iPhone15,2`-style identifier from `utsname.machine`. Returns
  333. /// `Simulator <SIMULATOR_MODEL_IDENTIFIER>` on the simulator so analysis
  334. /// can ignore those rows.
  335. static func hardwareIdentifier() -> String {
  336. #if targetEnvironment(simulator)
  337. let env = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "Unknown"
  338. return "Simulator \(env)"
  339. #else
  340. var sys = utsname()
  341. uname(&sys)
  342. let mirror = Mirror(reflecting: sys.machine)
  343. let machine = mirror.children.reduce(into: "") { acc, child in
  344. guard let v = child.value as? Int8, v != 0 else { return }
  345. acc.append(Character(UnicodeScalar(UInt8(v))))
  346. }
  347. return machine.isEmpty ? "Unknown" : machine
  348. #endif
  349. }
  350. static func detectPlatform() -> String {
  351. #if targetEnvironment(macCatalyst)
  352. return "macCatalyst"
  353. #else
  354. switch UIDevice.current.userInterfaceIdiom {
  355. case .pad: return "iPadOS"
  356. default: return "iOS"
  357. }
  358. #endif
  359. }
  360. }