TelemetryClient.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  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. func scheduleRecurring() {
  100. guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
  101. PropertyPersistentFlags.shared.telemetryEnabled == true
  102. else {
  103. return
  104. }
  105. lock.lock()
  106. defer { lock.unlock() }
  107. if timer == nil {
  108. let t = DispatchTimer(timeInterval: Self.dailyInterval)
  109. t.eventHandler = { [weak self] in
  110. Task.detached { await self?.maybeSend() }
  111. }
  112. t.resume()
  113. timer = t
  114. }
  115. }
  116. /// Single entry point for all sends (scheduler tick, consent-yes, startup
  117. /// SHA-change). Gated on consent + opt-in. *When* to send is the caller's
  118. /// decision — startup handles the SHA-change shortcut, the timer handles
  119. /// 24h cadence.
  120. func maybeSend() async {
  121. guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
  122. PropertyPersistentFlags.shared.telemetryEnabled == true
  123. else {
  124. return
  125. }
  126. await send()
  127. }
  128. // MARK: - Payload
  129. /// The exact payload that would be POSTed right now. Pure function: shared
  130. /// by `send()` and `TelemetryPreviewView`.
  131. func buildPayload() -> [String: Any] {
  132. injectIfNeeded()
  133. let bd = BuildDetails.shared
  134. let info = Bundle.main.infoDictionary ?? [:]
  135. var payload: [String: Any] = [:]
  136. if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v }
  137. payload["appName"] = TelemetryClient.telemetryAppName
  138. // appDevVersion is Trio's 4-component dev counter (e.g. "0.7.0.14") —
  139. // the most precise build identifier we have. Always emit, even when
  140. // the Info.plist key is missing, so dashboards can rely on the field.
  141. payload["appDevVersion"] = Bundle.main.appDevVersion ?? "unknown"
  142. payload["commitSha"] = bd.trioCommitSHA
  143. payload["branch"] = bd.trioBranch
  144. // Date-only (yyyy-MM-dd, UTC) build identifier, parsed from the
  145. // "Tue May 26 12:34:56 UTC 2025" form added in BuildDetails.plist.
  146. if let date = bd.buildDate() {
  147. payload["buildDate"] = Self.buildDateFormatter.string(from: date)
  148. }
  149. payload["isTestFlight"] = bd.isTestFlightBuild()
  150. if let idfv = UIDevice.current.identifierForVendor?.uuidString {
  151. payload["idfv"] = idfv
  152. }
  153. payload["installId"] = installId()
  154. payload["device"] = Self.hardwareIdentifier()
  155. payload["platform"] = Self.detectPlatform()
  156. payload["osVersion"] = UIDevice.current.systemVersion
  157. payload["locale"] = Locale.current.identifier
  158. payload["timeZone"] = TimeZone.current.identifier
  159. // Pump model — omitted entirely when no pump is paired.
  160. if let pump = apsManager?.pumpManager {
  161. payload["pumpModel"] = pump.localizedTitle
  162. }
  163. // CGM: enum tells us the configured *type*; the live manager (if any)
  164. // tells us the specific model name. Both are useful — `cgmType`
  165. // distinguishes Dexcom-via-Nightscout from Dexcom-via-direct, etc.
  166. let settings = settingsManager?.settings
  167. payload["cgmType"] = settings?.cgm.rawValue ?? CGMType.none.rawValue
  168. if let cgm = fetchGlucoseManager?.cgmManager {
  169. payload["cgmModel"] = cgm.localizedTitle
  170. }
  171. // Nightscout: keys present in keychain ⇒ configured. We never include
  172. // the URL or token themselves.
  173. let nsUrl = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey)?
  174. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  175. let nsSecret = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)?
  176. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  177. payload["nightscoutPaired"] = !nsUrl.isEmpty && !nsSecret.isEmpty
  178. payload["tidepoolPaired"] = tidepoolManager?.getTidepoolServiceUI() != nil
  179. // Apple Health: report `enabled = true` as soon as *any* per-type write
  180. // permission is granted, with the full per-type breakdown in
  181. // `appleHealthWrites`.
  182. let appleHealthSampleTypes: [(name: String, type: HKObjectType?)] = [
  183. ("glucose", AppleHealthConfig.healthBGObject),
  184. ("insulin", AppleHealthConfig.healthInsulinObject),
  185. ("carbs", AppleHealthConfig.healthCarbObject),
  186. ("fat", AppleHealthConfig.healthFatObject),
  187. ("protein", AppleHealthConfig.healthProteinObject)
  188. ]
  189. var writePermissions: [String: Bool] = [:]
  190. for (name, type) in appleHealthSampleTypes {
  191. let granted = type.flatMap { healthKitManager?.checkWriteToHealthPermissions(objectTypeToHealthStore: $0) } ?? false
  192. writePermissions[name] = granted
  193. }
  194. payload["appleHealthEnabled"] = writePermissions.values.contains(true)
  195. if !writePermissions.isEmpty {
  196. payload["appleHealthWrites"] = writePermissions
  197. }
  198. if let settings = settings {
  199. payload["closedLoop"] = settings.closedLoop
  200. payload["units"] = settings.units.rawValue
  201. payload["useLiveActivity"] = settings.useLiveActivity
  202. payload["useCalendar"] = settings.useCalendar
  203. }
  204. payload["coldLaunches7d"] = (PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []).count
  205. // Submodule SHAs — small, useful for tracking which LoopKit / OmniBLE /
  206. // etc. revision the user is on. Branch is dropped to keep payload size small.
  207. let submoduleShas = bd.submodules.mapValues { $0.commitSHA }
  208. if !submoduleShas.isEmpty {
  209. payload["submodules"] = submoduleShas
  210. }
  211. return payload
  212. }
  213. // MARK: - Send
  214. /// Build payload, attest it via App Attest, POST it, update last-sent state
  215. /// on 2xx. Fire-and-forget; errors are logged at debug level only.
  216. ///
  217. /// Flow:
  218. /// 1. Skip if `TelemetryAttestor.isSupported == false` (simulator, older
  219. /// devices). This is the primary opt-out for unsupported hardware —
  220. /// sending without attestation would just bounce off the server.
  221. /// 2. Skip if the install has been flagged forbidden by a previous 403.
  222. /// 3. Register if needed (idempotent; first launch + once on retry after
  223. /// transient failures).
  224. /// 4. Serialize the payload. Reject if > 4096 bytes (server-enforced cap).
  225. /// 5. Ask the attestor for an assertion over `SHA256(payload || challenge)`.
  226. /// 6. POST `/checkin` with the three App Attest headers.
  227. ///
  228. /// Backoff: failures don't update `telemetryLastSentAt`, so the next
  229. /// scheduler tick / cold launch retries naturally. The 24h cadence is the
  230. /// natural backoff floor; no per-attempt exponential timer is added.
  231. func send() async {
  232. guard let baseURL = Self.baseURL else {
  233. debug(.telemetry, "skip send: server URL not configured")
  234. return
  235. }
  236. let attestor = TelemetryAttestor.shared
  237. guard attestor.isSupported else {
  238. debug(.telemetry, "skip send: App Attest unsupported (simulator or older device)")
  239. return
  240. }
  241. guard !attestor.isForbidden else {
  242. debug(.telemetry, "skip send: app_id previously rejected (403)")
  243. return
  244. }
  245. do {
  246. try await attestor.registerIfNeeded(baseURL: baseURL)
  247. } catch TelemetryAttestor.AttestError.forbidden {
  248. // Already logged + sticky-flagged in registerIfNeeded.
  249. return
  250. } catch {
  251. debug(.telemetry, "register failed: \(error) — will retry next cycle")
  252. return
  253. }
  254. let payload = buildPayload()
  255. guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
  256. debug(.telemetry, "skip send: payload not JSON-serializable")
  257. return
  258. }
  259. guard body.count <= Self.maxPayloadBytes else {
  260. debug(.telemetry, "skip send: payload exceeds \(Self.maxPayloadBytes) bytes (\(body.count))")
  261. return
  262. }
  263. let assertion: (assertion: String, keyID: String, challenge: String)
  264. do {
  265. assertion = try await attestor.assertion(forPayload: body, baseURL: baseURL)
  266. } catch {
  267. debug(.telemetry, "assertion failed: \(error)")
  268. return
  269. }
  270. var request = URLRequest(url: baseURL.appendingPathComponent("checkin"))
  271. request.httpMethod = "POST"
  272. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  273. request.setValue(assertion.keyID, forHTTPHeaderField: "X-AppAttest-KeyId")
  274. request.setValue(assertion.assertion, forHTTPHeaderField: "X-AppAttest-Assertion")
  275. request.setValue(assertion.challenge, forHTTPHeaderField: "X-Challenge")
  276. request.httpBody = body
  277. request.timeoutInterval = 15
  278. do {
  279. let (_, response) = try await URLSession.shared.data(for: request)
  280. guard let http = response as? HTTPURLResponse else {
  281. debug(.telemetry, "send: non-HTTP response")
  282. return
  283. }
  284. switch http.statusCode {
  285. case 200 ..< 300:
  286. PropertyPersistentFlags.shared.telemetryLastSentAt = Date()
  287. PropertyPersistentFlags.shared.telemetryLastSentSha = BuildDetails.shared.trioCommitSHA
  288. debug(.telemetry, "send ok status=\(http.statusCode)")
  289. case 401:
  290. // Server doesn't recognize our registration (e.g. its registry
  291. // was wiped). Drop the local keyID + registered flag so the
  292. // next cycle generates a fresh key and re-attests — `attestKey`
  293. // can't be re-run on the existing keyID (one-shot per Apple).
  294. attestor.invalidateRegistration()
  295. debug(.telemetry, "send 401: stale registration, will re-register next cycle")
  296. default:
  297. debug(.telemetry, "send non-2xx status=\(http.statusCode)")
  298. }
  299. } catch {
  300. debug(.telemetry, "send error: \(error.localizedDescription)")
  301. }
  302. }
  303. // MARK: - Helpers
  304. /// `iPhone15,2`-style identifier from `utsname.machine`. Returns
  305. /// `Simulator <SIMULATOR_MODEL_IDENTIFIER>` on the simulator so analysis
  306. /// can ignore those rows.
  307. static func hardwareIdentifier() -> String {
  308. #if targetEnvironment(simulator)
  309. let env = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "Unknown"
  310. return "Simulator \(env)"
  311. #else
  312. var sys = utsname()
  313. uname(&sys)
  314. let mirror = Mirror(reflecting: sys.machine)
  315. let machine = mirror.children.reduce(into: "") { acc, child in
  316. guard let v = child.value as? Int8, v != 0 else { return }
  317. acc.append(Character(UnicodeScalar(UInt8(v))))
  318. }
  319. return machine.isEmpty ? "Unknown" : machine
  320. #endif
  321. }
  322. static func detectPlatform() -> String {
  323. #if targetEnvironment(macCatalyst)
  324. return "macCatalyst"
  325. #else
  326. switch UIDevice.current.userInterfaceIdiom {
  327. case .pad: return "iPadOS"
  328. default: return "iOS"
  329. }
  330. #endif
  331. }
  332. }