TelemetryClient.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  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. private static let productionBaseURL: URL? = URL(string: "https://telemetry.triodocs.org")
  18. /// Effective base URL: respects the debug override in
  19. /// `PropertyPersistentFlags.telemetryDebugServerURL`, then falls back to
  20. /// `productionBaseURL`. Used by both the registration and `/checkin` paths.
  21. private static var baseURL: URL? {
  22. if let override = PropertyPersistentFlags.shared.telemetryDebugServerURL?
  23. .trimmingCharacters(in: .whitespacesAndNewlines),
  24. !override.isEmpty,
  25. let url = URL(string: override)
  26. {
  27. return url
  28. }
  29. return productionBaseURL
  30. }
  31. private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60
  32. private static let dailyInterval: TimeInterval = 24 * 60 * 60
  33. private static let maxPayloadBytes = 4096
  34. // MARK: Injected services
  35. @Injected() private var apsManager: APSManager!
  36. @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
  37. @Injected() private var settingsManager: SettingsManager!
  38. @Injected() private var tidepoolManager: TidepoolManager!
  39. @Injected() private var healthKitManager: HealthKitManager!
  40. @Injected() private var keychain: Keychain!
  41. private let lock = NSRecursiveLock()
  42. private var didInjectServices = false
  43. private var timer: DispatchTimer?
  44. private init() {}
  45. private func injectIfNeeded() {
  46. lock.lock()
  47. defer { lock.unlock() }
  48. guard !didInjectServices else { return }
  49. injectServices(TrioApp.resolver)
  50. didInjectServices = true
  51. }
  52. // MARK: - Cold launches
  53. /// Records a cold launch in a sliding 7-day window of timestamps. The count
  54. /// of entries in the window ships as `coldLaunches7d` in every ping — a
  55. /// "how often does iOS recycle this process" signal that is directly
  56. /// comparable across pings regardless of the cadence between them.
  57. func recordColdLaunch(now: Date = Date()) {
  58. let cutoff = now.addingTimeInterval(-Self.weeklyInterval)
  59. var recent = PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []
  60. recent.removeAll { $0 < cutoff }
  61. recent.append(now)
  62. PropertyPersistentFlags.shared.telemetryColdLaunchTimes = recent
  63. }
  64. // MARK: - Install identifier
  65. /// Stable per-install UUID, generated lazily on first call. IDFV resets if
  66. /// the user deletes every Trio-team app at once; this survives
  67. /// independently and is wiped only by deleting Trio itself.
  68. private func installId() -> String {
  69. if let existing = PropertyPersistentFlags.shared.telemetryInstallId, !existing.isEmpty {
  70. return existing
  71. }
  72. let new = UUID().uuidString
  73. PropertyPersistentFlags.shared.telemetryInstallId = new
  74. return new
  75. }
  76. // MARK: - Cadence
  77. /// True when the running build's commit SHA differs from the SHA recorded
  78. /// at the last successful send. Used at startup to fire one immediate ping
  79. /// after an app update — the 24h scheduler can't notice a build change and
  80. /// would otherwise wait out the previous interval.
  81. func buildShaChangedSinceLastSend() -> Bool {
  82. let currentSha = BuildDetails.shared.trioCommitSHA
  83. return PropertyPersistentFlags.shared.telemetryLastSentSha != currentSha
  84. }
  85. /// Arms (or re-arms) the 24h send timer. Idempotent. Bails out without
  86. /// scheduling if the user hasn't decided on consent yet or has opted out
  87. /// — there's nothing for the timer to do.
  88. func scheduleRecurring() {
  89. guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
  90. PropertyPersistentFlags.shared.telemetryEnabled == true
  91. else {
  92. return
  93. }
  94. lock.lock()
  95. defer { lock.unlock() }
  96. if timer == nil {
  97. let t = DispatchTimer(timeInterval: Self.dailyInterval)
  98. t.eventHandler = { [weak self] in
  99. Task.detached { await self?.maybeSend() }
  100. }
  101. t.resume()
  102. timer = t
  103. }
  104. }
  105. /// Single entry point for all sends (scheduler tick, consent-yes, startup
  106. /// SHA-change). Gated on consent + opt-in. *When* to send is the caller's
  107. /// decision — startup handles the SHA-change shortcut, the timer handles
  108. /// 24h cadence.
  109. func maybeSend() async {
  110. guard PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true,
  111. PropertyPersistentFlags.shared.telemetryEnabled == true
  112. else {
  113. return
  114. }
  115. await send()
  116. }
  117. // MARK: - Payload
  118. /// The exact payload that would be POSTed right now. Pure function: shared
  119. /// by `send()` and `TelemetryPreviewView`.
  120. func buildPayload() -> [String: Any] {
  121. injectIfNeeded()
  122. let bd = BuildDetails.shared
  123. let info = Bundle.main.infoDictionary ?? [:]
  124. var payload: [String: Any] = [:]
  125. if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v }
  126. // appDevVersion is Trio's 4-component dev counter (e.g. "0.7.0.14") —
  127. // the most precise build identifier we have. Always emit, even when
  128. // the Info.plist key is missing, so dashboards can rely on the field.
  129. payload["appDevVersion"] = Bundle.main.appDevVersion ?? "unknown"
  130. payload["commitSha"] = bd.trioCommitSHA
  131. payload["branch"] = bd.trioBranch
  132. // Date-only prefix of the build-date string. Keeps the field a
  133. // low-resolution build identifier, not a precise timestamp.
  134. if let raw = bd.buildDateString, raw.count >= 10 {
  135. payload["buildDate"] = String(raw.prefix(10))
  136. }
  137. payload["isTestFlight"] = bd.isTestFlightBuild()
  138. if let idfv = UIDevice.current.identifierForVendor?.uuidString {
  139. payload["idfv"] = idfv
  140. }
  141. payload["installId"] = installId()
  142. payload["device"] = Self.hardwareIdentifier()
  143. payload["platform"] = Self.detectPlatform()
  144. payload["osVersion"] = UIDevice.current.systemVersion
  145. // Pump model — omitted entirely when no pump is paired.
  146. if let pump = apsManager?.pumpManager {
  147. payload["pumpModel"] = pump.localizedTitle
  148. }
  149. // CGM: enum tells us the configured *type*; the live manager (if any)
  150. // tells us the specific model name. Both are useful — `cgmType`
  151. // distinguishes Dexcom-via-Nightscout from Dexcom-via-direct, etc.
  152. let settings = settingsManager?.settings
  153. payload["cgmType"] = settings?.cgm.rawValue ?? CGMType.none.rawValue
  154. if let cgm = fetchGlucoseManager?.cgmManager {
  155. payload["cgmModel"] = cgm.localizedTitle
  156. }
  157. // Nightscout: keys present in keychain ⇒ configured. We never include
  158. // the URL or token themselves.
  159. let nsUrl = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.urlKey)?
  160. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  161. let nsSecret = keychain?.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)?
  162. .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  163. payload["nightscoutPaired"] = !nsUrl.isEmpty && !nsSecret.isEmpty
  164. payload["tidepoolPaired"] = tidepoolManager?.getTidepoolServiceUI() != nil
  165. let useHealth = settings?.useAppleHealth ?? false
  166. let healthAuthorized = healthKitManager?.hasGrantedFullWritePermissions ?? false
  167. payload["appleHealthEnabled"] = useHealth && healthAuthorized
  168. if let settings = settings {
  169. payload["closedLoop"] = settings.closedLoop
  170. payload["units"] = settings.units.rawValue
  171. payload["useLiveActivity"] = settings.useLiveActivity
  172. payload["useCalendar"] = settings.useCalendar
  173. }
  174. payload["coldLaunches7d"] = (PropertyPersistentFlags.shared.telemetryColdLaunchTimes ?? []).count
  175. // Submodule SHAs — small, useful for tracking which LoopKit / OmniBLE /
  176. // etc. revision the user is on. Branch is dropped to keep payload size small.
  177. let submoduleShas = bd.submodules.mapValues { $0.commitSHA }
  178. if !submoduleShas.isEmpty {
  179. payload["submodules"] = submoduleShas
  180. }
  181. return payload
  182. }
  183. // MARK: - Send
  184. /// Build payload, attest it via App Attest, POST it, update last-sent state
  185. /// on 2xx. Fire-and-forget; errors are logged at debug level only.
  186. ///
  187. /// Flow:
  188. /// 1. Skip if `TelemetryAttestor.isSupported == false` (simulator, older
  189. /// devices). This is the primary opt-out for unsupported hardware —
  190. /// sending without attestation would just bounce off the server.
  191. /// 2. Skip if the install has been flagged forbidden by a previous 403.
  192. /// 3. Register if needed (idempotent; first launch + once on retry after
  193. /// transient failures).
  194. /// 4. Serialize the payload. Reject if > 4096 bytes (server-enforced cap).
  195. /// 5. Ask the attestor for an assertion over `SHA256(payload || challenge)`.
  196. /// 6. POST `/checkin` with the three App Attest headers.
  197. ///
  198. /// Backoff: failures don't update `telemetryLastSentAt`, so the next
  199. /// scheduler tick / cold launch retries naturally. The 24h cadence is the
  200. /// natural backoff floor; no per-attempt exponential timer is added.
  201. func send() async {
  202. guard let baseURL = Self.baseURL else {
  203. debug(.telemetry, "skip send: server URL not configured")
  204. return
  205. }
  206. let attestor = TelemetryAttestor.shared
  207. guard attestor.isSupported else {
  208. debug(.telemetry, "skip send: App Attest unsupported (simulator or older device)")
  209. return
  210. }
  211. guard !attestor.isForbidden else {
  212. debug(.telemetry, "skip send: app_id previously rejected (403)")
  213. return
  214. }
  215. do {
  216. try await attestor.registerIfNeeded(baseURL: baseURL)
  217. } catch TelemetryAttestor.AttestError.forbidden {
  218. // Already logged + sticky-flagged in registerIfNeeded.
  219. return
  220. } catch {
  221. debug(.telemetry, "register failed: \(error) — will retry next cycle")
  222. return
  223. }
  224. let payload = buildPayload()
  225. guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
  226. debug(.telemetry, "skip send: payload not JSON-serializable")
  227. return
  228. }
  229. guard body.count <= Self.maxPayloadBytes else {
  230. debug(.telemetry, "skip send: payload exceeds \(Self.maxPayloadBytes) bytes (\(body.count))")
  231. return
  232. }
  233. let assertion: (assertion: String, keyID: String, challenge: String)
  234. do {
  235. assertion = try await attestor.assertion(forPayload: body, baseURL: baseURL)
  236. } catch {
  237. debug(.telemetry, "assertion failed: \(error)")
  238. return
  239. }
  240. var request = URLRequest(url: baseURL.appendingPathComponent("checkin"))
  241. request.httpMethod = "POST"
  242. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  243. request.setValue(assertion.keyID, forHTTPHeaderField: "X-AppAttest-KeyId")
  244. request.setValue(assertion.assertion, forHTTPHeaderField: "X-AppAttest-Assertion")
  245. request.setValue(assertion.challenge, forHTTPHeaderField: "X-Challenge")
  246. request.httpBody = body
  247. request.timeoutInterval = 15
  248. do {
  249. let (_, response) = try await URLSession.shared.data(for: request)
  250. guard let http = response as? HTTPURLResponse else {
  251. debug(.telemetry, "send: non-HTTP response")
  252. return
  253. }
  254. switch http.statusCode {
  255. case 200 ..< 300:
  256. PropertyPersistentFlags.shared.telemetryLastSentAt = Date()
  257. PropertyPersistentFlags.shared.telemetryLastSentSha = BuildDetails.shared.trioCommitSHA
  258. debug(.telemetry, "send ok status=\(http.statusCode)")
  259. case 401:
  260. // Server doesn't recognize our registration (e.g. its registry
  261. // was wiped). Drop the local keyID + registered flag so the
  262. // next cycle generates a fresh key and re-attests — `attestKey`
  263. // can't be re-run on the existing keyID (one-shot per Apple).
  264. attestor.invalidateRegistration()
  265. debug(.telemetry, "send 401: stale registration, will re-register next cycle")
  266. default:
  267. debug(.telemetry, "send non-2xx status=\(http.statusCode)")
  268. }
  269. } catch {
  270. debug(.telemetry, "send error: \(error.localizedDescription)")
  271. }
  272. }
  273. // MARK: - Helpers
  274. /// `iPhone15,2`-style identifier from `utsname.machine`. Returns
  275. /// `Simulator <SIMULATOR_MODEL_IDENTIFIER>` on the simulator so analysis
  276. /// can ignore those rows.
  277. static func hardwareIdentifier() -> String {
  278. #if targetEnvironment(simulator)
  279. let env = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "Unknown"
  280. return "Simulator \(env)"
  281. #else
  282. var sys = utsname()
  283. uname(&sys)
  284. let mirror = Mirror(reflecting: sys.machine)
  285. let machine = mirror.children.reduce(into: "") { acc, child in
  286. guard let v = child.value as? Int8, v != 0 else { return }
  287. acc.append(Character(UnicodeScalar(UInt8(v))))
  288. }
  289. return machine.isEmpty ? "Unknown" : machine
  290. #endif
  291. }
  292. static func detectPlatform() -> String {
  293. #if targetEnvironment(macCatalyst)
  294. return "macCatalyst"
  295. #else
  296. switch UIDevice.current.userInterfaceIdiom {
  297. case .pad: return "iPadOS"
  298. default: return "iOS"
  299. }
  300. #endif
  301. }
  302. }