Telemetry.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. // LoopFollow
  2. // Telemetry.swift
  3. import CryptoKit
  4. import Foundation
  5. import SwiftUI
  6. import UIKit
  7. // MARK: - TelemetryClient
  8. final class TelemetryClient {
  9. static let shared = TelemetryClient()
  10. private static let endpoint = URL(string: "https://lf.bjorkert.se/api/telemetry/checkin")!
  11. private static let salt = "lf-telemetry"
  12. private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60
  13. private static let dailyInterval: TimeInterval = 24 * 60 * 60
  14. private init() {}
  15. /// Records a cold launch in a sliding 7-day window of timestamps. Called
  16. /// from AppDelegate.didFinishLaunchingWithOptions on every process start
  17. /// (foreground or background). The count of entries in the window is sent
  18. /// as `coldLaunches7d` in each ping, giving a "how often is iOS recycling
  19. /// or killing this process" signal that's directly comparable across
  20. /// pings regardless of the cadence between them.
  21. func recordColdLaunch(now: Date = Date()) {
  22. let cutoff = now.addingTimeInterval(-Self.weeklyInterval)
  23. var recent = Storage.shared.telemetryColdLaunchTimes.value
  24. recent.removeAll { $0 < cutoff }
  25. recent.append(now)
  26. Storage.shared.telemetryColdLaunchTimes.value = recent
  27. }
  28. /// Static write token, committed in source. The LoopFollow repo is public,
  29. /// so this string is public too. The backend treats it as a "front door
  30. /// sign" rather than a secret: TLS, NGINX rate limit (60 req/min/IP),
  31. /// strict schema validation, and an insert+find-only MongoDB role bound
  32. /// any abuse to harmless duplicate-row inserts.
  33. private static let writeToken = "RsEDJ8RoOs7HHZ_XGOdI1sY3Yuv6iPnRRk7tg-NlCAg"
  34. /// True when the running build's commit SHA differs from the SHA recorded
  35. /// at the last successful send. Used at startup to fire one immediate
  36. /// ping after an app update — the regular 24h scheduler can't tell that
  37. /// the build changed and would otherwise wait out the previous interval.
  38. func buildShaChangedSinceLastSend() -> Bool {
  39. let currentSha = BuildDetails.default.commitSha ?? ""
  40. return Storage.shared.telemetryLastSentSha.value != currentSha
  41. }
  42. /// Wires telemetry into TaskScheduler. Called once on app start (from
  43. /// AppDelegate.didFinishLaunchingWithOptions) and again after each tick.
  44. /// First run is computed from `telemetryLastSentAt`: a relaunch 6h after
  45. /// the previous send waits 18h; a relaunch after 25h fires on the next
  46. /// timer tick (TaskScheduler treats a past nextRun as "fire soon"). Each
  47. /// fired tick reschedules itself for +24h, giving the steady-state
  48. /// cadence while the app keeps running.
  49. ///
  50. /// Bails out without scheduling if the user hasn't decided on consent
  51. /// yet or has opted out — there's nothing for the timer to do, and
  52. /// scheduling for "now" with `lastSentAt` still nil would tight-loop
  53. /// (fire → maybeSend bails → reschedule → fire …).
  54. func scheduleRecurring() {
  55. let storage = Storage.shared
  56. guard storage.telemetryConsentDecisionMade.value,
  57. storage.telemetryEnabled.value
  58. else {
  59. return
  60. }
  61. let nextRun: Date
  62. if let last = storage.telemetryLastSentAt.value {
  63. nextRun = last.addingTimeInterval(Self.dailyInterval)
  64. } else {
  65. // Consent given but we've never landed a successful send
  66. // (network down at first launch, server hiccup, etc). Retry in
  67. // a minute — bounded so a persistently failing send doesn't
  68. // turn into a busy loop.
  69. nextRun = Date().addingTimeInterval(60)
  70. }
  71. TaskScheduler.shared.scheduleTask(id: .telemetry, nextRun: nextRun) {
  72. Task.detached {
  73. await TelemetryClient.shared.maybeSend()
  74. TelemetryClient.shared.scheduleRecurring()
  75. }
  76. }
  77. }
  78. /// Single entry point used by all callers (scheduler tick, consent-yes,
  79. /// startup SHA-change). Gated only by consent + opt-in toggle; *when* to
  80. /// send is the caller's decision (the scheduler handles the 24h cadence
  81. /// by setting `nextRun`; startup handles the SHA-change shortcut).
  82. func maybeSend() async {
  83. let storage = Storage.shared
  84. guard storage.telemetryConsentDecisionMade.value else { return }
  85. guard storage.telemetryEnabled.value else { return }
  86. await send()
  87. }
  88. /// The exact payload that would be POSTed right now. Pure function: useful
  89. /// both for sending and for the "What's sent" preview UI.
  90. func buildPayload() -> [String: Any] {
  91. let storage = Storage.shared
  92. let info = Bundle.main.infoDictionary ?? [:]
  93. let bd = BuildDetails.default
  94. var payload: [String: Any] = [:]
  95. if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v }
  96. // Date-only (YYYY-MM-DD) prefix of the ISO8601 build date. Time is
  97. // dropped to keep the payload to a low-resolution build identifier.
  98. if let date = bd.buildDateString, date.count >= 10 {
  99. payload["buildDate"] = String(date.prefix(10))
  100. }
  101. // Only signal we can actually verify: receipt-based TestFlight check.
  102. // macCatalyst is covered by `platform`; simulator is covered by the
  103. // `Simulator …` prefix on `device`. Anything else is a local Xcode
  104. // build (browser-build), which is just "isTestFlight == false".
  105. payload["isTestFlight"] = bd.isTestFlightBuild()
  106. payload["instance"] = AppConstants.appInstanceId
  107. if let idfv = UIDevice.current.identifierForVendor?.uuidString {
  108. payload["idfv"] = idfv
  109. }
  110. payload["device"] = Self.hardwareIdentifier()
  111. payload["platform"] = Self.detectPlatform()
  112. payload["osVersion"] = UIDevice.current.systemVersion
  113. let dexcomUser = storage.shareUserName.value.trimmingCharacters(in: .whitespacesAndNewlines)
  114. payload["usesDexcom"] = !dexcomUser.isEmpty
  115. let nsURLRaw = storage.url.value.trimmingCharacters(in: .whitespacesAndNewlines)
  116. payload["usesNightscout"] = !nsURLRaw.isEmpty
  117. // Which closed-loop app is being followed (Loop / Trio / …). Field
  118. // omitted when device hasn't been detected yet; absence is the signal.
  119. let device = storage.device.value.trimmingCharacters(in: .whitespacesAndNewlines)
  120. if !device.isEmpty {
  121. payload["followingApp"] = device
  122. }
  123. payload["backgroundRefreshMethod"] = storage.backgroundRefreshType.value.rawValue
  124. // Selected user-preference fields. Picked for product-decision value;
  125. // none reveal personal or health information.
  126. payload["units"] = storage.units.value // "mg/dL" / "mmol/L"
  127. payload["remoteType"] = storage.remoteType.value.rawValue // which remote-command path
  128. payload["appearanceMode"] = storage.appearanceMode.value.rawValue // light / dark / system
  129. payload["contactEnabled"] = storage.contactEnabled.value // Contacts integration on?
  130. payload["calendarEnabled"] = !storage.calendarIdentifier.value.isEmpty // calendar selected?
  131. payload["coldLaunches7d"] = storage.telemetryColdLaunchTimes.value.count
  132. return payload
  133. }
  134. /// Build payload, POST it, update last-sent state on 2xx. Fire-and-forget;
  135. /// errors are logged at debug level only and never surfaced to the UI.
  136. func send() async {
  137. let storage = Storage.shared
  138. let payload = buildPayload()
  139. guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
  140. LogManager.shared.log(category: .telemetry, message: "skip send: payload not JSON-serializable", isDebug: true)
  141. return
  142. }
  143. var request = URLRequest(url: Self.endpoint)
  144. request.httpMethod = "POST"
  145. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  146. request.setValue("Bearer \(Self.writeToken)", forHTTPHeaderField: "Authorization")
  147. request.httpBody = body
  148. request.timeoutInterval = 15
  149. do {
  150. let (_, response) = try await URLSession.shared.data(for: request)
  151. guard let http = response as? HTTPURLResponse else {
  152. LogManager.shared.log(category: .telemetry, message: "send: non-HTTP response", isDebug: true)
  153. return
  154. }
  155. if (200 ..< 300).contains(http.statusCode) {
  156. let now = Date()
  157. let sha = BuildDetails.default.commitSha ?? ""
  158. storage.telemetryLastSentAt.value = now
  159. storage.telemetryLastSentSha.value = sha
  160. LogManager.shared.log(category: .telemetry, message: "send ok status=\(http.statusCode)", isDebug: true)
  161. } else {
  162. LogManager.shared.log(category: .telemetry, message: "send non-2xx status=\(http.statusCode)", isDebug: true)
  163. }
  164. } catch {
  165. LogManager.shared.log(category: .telemetry, message: "send error: \(error.localizedDescription)", isDebug: true)
  166. }
  167. }
  168. // MARK: - Helpers
  169. /// Salted SHA-256, truncated to 16 hex chars (64 bits).
  170. static func hashed(_ raw: String) -> String {
  171. let canonical = raw.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
  172. let input = Data((salt + canonical).utf8)
  173. let digest = SHA256.hash(data: input)
  174. return digest.prefix(8).map { String(format: "%02x", $0) }.joined()
  175. }
  176. /// `iPhone15,2`-style identifier from `utsname.machine`. Returns
  177. /// `Simulator <SIMULATOR_MODEL_IDENTIFIER>` on the simulator so analysis
  178. /// can ignore those rows.
  179. static func hardwareIdentifier() -> String {
  180. #if targetEnvironment(simulator)
  181. let env = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "Unknown"
  182. return "Simulator \(env)"
  183. #else
  184. var sys = utsname()
  185. uname(&sys)
  186. let mirror = Mirror(reflecting: sys.machine)
  187. let machine = mirror.children.reduce(into: "") { acc, child in
  188. guard let v = child.value as? Int8, v != 0 else { return }
  189. acc.append(Character(UnicodeScalar(UInt8(v))))
  190. }
  191. return machine.isEmpty ? "Unknown" : machine
  192. #endif
  193. }
  194. static func detectPlatform() -> String {
  195. #if targetEnvironment(macCatalyst)
  196. return "macCatalyst"
  197. #else
  198. switch UIDevice.current.userInterfaceIdiom {
  199. case .pad: return "iPadOS"
  200. default: return "iOS"
  201. }
  202. #endif
  203. }
  204. }
  205. // MARK: - TelemetryPreviewView
  206. /// Renders the exact payload that would be sent right now, with a copy
  207. /// button. Linked to from the Diagnostics section in Settings and from the
  208. /// consent sheet's "See exactly what's sent" button.
  209. struct TelemetryPreviewView: View {
  210. @State private var jsonText: String = ""
  211. var body: some View {
  212. ScrollView {
  213. VStack(alignment: .leading, spacing: 12) {
  214. Text("Below is the exact JSON object that LoopFollow would send to lf.bjorkert.se right now.")
  215. .font(.subheadline)
  216. .foregroundColor(.secondary)
  217. .padding(.bottom, 4)
  218. Text(jsonText)
  219. .font(.system(.footnote, design: .monospaced))
  220. .frame(maxWidth: .infinity, alignment: .leading)
  221. .textSelection(.enabled)
  222. .padding(8)
  223. .background(Color(.secondarySystemBackground))
  224. .cornerRadius(6)
  225. Button {
  226. UIPasteboard.general.string = jsonText
  227. } label: {
  228. Label("Copy JSON", systemImage: "doc.on.doc")
  229. }
  230. .buttonStyle(.bordered)
  231. }
  232. .padding()
  233. }
  234. .navigationTitle("What's sent")
  235. .navigationBarTitleDisplayMode(.inline)
  236. .onAppear { jsonText = Self.renderPayload() }
  237. }
  238. private static func renderPayload() -> String {
  239. let payload = TelemetryClient.shared.buildPayload()
  240. guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]),
  241. let text = String(data: data, encoding: .utf8)
  242. else { return "Unable to render payload." }
  243. return text
  244. }
  245. }
  246. // MARK: - TelemetryPrivacyView
  247. /// In-app summary so users don't have to leave the app to understand
  248. /// what is collected.
  249. struct TelemetryPrivacyView: View {
  250. var body: some View {
  251. ScrollView {
  252. VStack(alignment: .leading, spacing: 16) {
  253. Group {
  254. Text("Endpoint")
  255. .font(.headline)
  256. Text("Once a day (or after a new build), the app sends a small JSON object to https://lf.bjorkert.se. The endpoint is self-hosted by the maintainer; no third-party analytics service is involved.")
  257. }
  258. Group {
  259. Text("What is sent")
  260. .font(.headline)
  261. Text("App version, build date, whether this is a TestFlight build, the install instance number, an Apple-supplied per-vendor identifier (IDFV) that resets when all this developer's apps are removed from the device, the hardware identifier (e.g. iPhone15,2), and iOS version. Whether Nightscout and Dexcom are configured (yes/no — no URLs or usernames). Which app you're following (Loop, Trio, etc), if known. A small set of preference flags (units, appearance mode, calendar/contact integration enabled, remote-command type, background refresh method). The full JSON is visible under Diagnostics → What's sent.")
  262. }
  263. Group {
  264. Text("What stays on your device")
  265. .font(.headline)
  266. Text("All glucose, insulin, and carb data. Your Nightscout URL and API token. Your Dexcom credentials. Remote-command secrets and APNS keys. Time zone. Location data. Logs — these are never sent automatically; the Settings → Logs sharing flow is unchanged and only triggered by you.")
  267. }
  268. Group {
  269. Text("Frequency")
  270. .font(.headline)
  271. Text("Once every 24 hours, plus once after installing a new build. The check runs in the background while the app is active or refreshing in the background.")
  272. }
  273. Group {
  274. Text("Opt out")
  275. .font(.headline)
  276. Text("Use the Send anonymous usage stats toggle above. Turning it off is immediate and persistent.")
  277. }
  278. Group {
  279. Text("Source")
  280. .font(.headline)
  281. Text("LoopFollow/Helpers/Telemetry.swift on GitHub.")
  282. }
  283. }
  284. .padding()
  285. }
  286. .navigationTitle("Privacy")
  287. .navigationBarTitleDisplayMode(.inline)
  288. }
  289. }
  290. // MARK: - TelemetryConsentView
  291. /// One-time prompt shown the first time the app foregrounds after install
  292. /// or after an update from a pre-telemetry version.
  293. struct TelemetryConsentView: View {
  294. @Environment(\.dismiss) private var dismiss
  295. var body: some View {
  296. NavigationView {
  297. ScrollView {
  298. VStack(alignment: .leading, spacing: 16) {
  299. Text("You can choose to share anonymous information with the developers to help improve LoopFollow—such as app and iOS version, device type, which app you're following, and a few settings. Your health data, credentials, time zone, and logs remain on your device.")
  300. Text("You can change this any time in Settings → General → Diagnostics.")
  301. .font(.subheadline)
  302. .foregroundColor(.secondary)
  303. NavigationLink {
  304. TelemetryPreviewView()
  305. } label: {
  306. Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass")
  307. }
  308. .padding(.top, 4)
  309. }
  310. .padding()
  311. }
  312. .navigationTitle("Help us help you!")
  313. .navigationBarTitleDisplayMode(.inline)
  314. .safeAreaInset(edge: .bottom) {
  315. VStack(spacing: 8) {
  316. Button {
  317. Storage.shared.telemetryEnabled.value = true
  318. Storage.shared.telemetryConsentDecisionMade.value = true
  319. // Fire the inaugural ping immediately, then start the
  320. // 24h scheduled cadence ticking from that send.
  321. Task.detached {
  322. await TelemetryClient.shared.maybeSend()
  323. TelemetryClient.shared.scheduleRecurring()
  324. }
  325. dismiss()
  326. } label: {
  327. Text("Yes, send anonymous stats")
  328. .frame(maxWidth: .infinity)
  329. }
  330. .buttonStyle(.borderedProminent)
  331. Button {
  332. Storage.shared.telemetryEnabled.value = false
  333. Storage.shared.telemetryConsentDecisionMade.value = true
  334. dismiss()
  335. } label: {
  336. Text("No thanks")
  337. .frame(maxWidth: .infinity)
  338. }
  339. .buttonStyle(.bordered)
  340. }
  341. .padding(.horizontal)
  342. .padding(.bottom, 12)
  343. .background(.bar)
  344. }
  345. }
  346. }
  347. }