From 684b51a854751a094b47f5c1356f546478186b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 3 Nov 2026 09:19:44 +0100 Subject: [PATCH 1/2] Send usage telemetry by default and report each new build Telemetry is on unless the user turned it off; the consent sheet and the onboarding step are gone, and a stored opt-out is kept. A new build is reported on its first launch, and the daily check runs at launch, on foreground and from the scheduler, with a one-hour wait after a failed attempt. Installs that turned sharing off send a minimal daily check-in with the app version, build and a random install ID. --- LoopFollow/Application/AppDelegate.swift | 10 +- LoopFollow/Application/MainTabView.swift | 38 +- LoopFollow/Helpers/Telemetry.swift | 582 +++++++++++++----- .../Onboarding/OnboardingContainerView.swift | 2 - LoopFollow/Onboarding/OnboardingStep.swift | 4 +- .../Onboarding/OnboardingViewModel.swift | 15 +- .../Onboarding/Steps/TelemetryStepView.swift | 79 --- LoopFollow/Settings/GeneralSettingsView.swift | 2 + LoopFollow/Storage/Storage.swift | 9 +- Tests/TelemetrySendGateTests.swift | 117 ++++ 10 files changed, 553 insertions(+), 305 deletions(-) delete mode 100644 LoopFollow/Onboarding/Steps/TelemetryStepView.swift create mode 100644 Tests/TelemetrySendGateTests.swift diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 9a061cc1f..eb6d1dc77 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -79,15 +79,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // window — poisoned defaults would discard real history. Runs synchronously // on a normal launch. StorageReadiness.whenReady { - // SHA change fires an immediate ping (the scheduler can't notice an app - // update); otherwise the 24h scheduler handles cadence. See Telemetry.swift. - TelemetryClient.shared.recordColdLaunch() - Task.detached { - if TelemetryClient.shared.buildShaChangedSinceLastSend() { - await TelemetryClient.shared.maybeSend() - } - TelemetryClient.shared.scheduleRecurring() - } + TelemetryClient.shared.start() } return true diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift index cd7bb7a40..504d2226c 100644 --- a/LoopFollow/Application/MainTabView.swift +++ b/LoopFollow/Application/MainTabView.swift @@ -15,7 +15,6 @@ struct MainTabView: View { @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition @ObservedObject private var activeBanner = Observable.shared.activeBanner - @State private var showTelemetryConsent = false @State private var showOnboarding = false private var orderedItems: [TabItem] { @@ -65,48 +64,23 @@ struct MainTabView: View { MainViewController.bootstrap() // Show the first-run onboarding once for everyone. Returning users - // get a prominent Skip on the welcome screen. The telemetry consent - // prompt is deferred until onboarding is dismissed so the two never - // appear on top of one another. + // get a prominent Skip on the welcome screen. if !Storage.shared.hasCompletedOnboarding.value { Observable.shared.isOnboardingActive.value = true showOnboarding = true } else { - runPostOnboardingPrompts() + requestNotificationsIfAlarmsEnabled() } } .fullScreenCover(isPresented: $showOnboarding, onDismiss: { Observable.shared.isOnboardingActive.value = false - // Covers both finishing and skipping onboarding — the telemetry and - // notification steps live inside the flow, so anyone who skips still - // needs these handled here. - runPostOnboardingPrompts() - }) { - OnboardingContainerView(onClose: { showOnboarding = false }) - } - .sheet(isPresented: $showTelemetryConsent, onDismiss: { - // Ask for notifications only once telemetry is resolved, so the system - // prompt never stacks on top of the consent sheet. + // Covers both finishing and skipping onboarding — the notification + // step lives inside the flow, so anyone who skips still needs it + // handled here. requestNotificationsIfAlarmsEnabled() }) { - // User must explicitly choose — no swipe-to-dismiss. - TelemetryConsentView() - .interactiveDismissDisabled(true) - } - } - - /// Runs after onboarding closes, whether it was completed or skipped. Telemetry - /// consent and notification permission both live inside the onboarding flow, so - /// a skip would otherwise bypass them. Telemetry consent goes first (as a - /// sheet); the notification request follows on its dismissal so the two never - /// appear at once. When the user completed the flow these are already decided, - /// so both calls are no-ops. - private func runPostOnboardingPrompts() { - if !Storage.shared.telemetryConsentDecisionMade.value { - showTelemetryConsent = true // notifications requested on its dismiss - } else { - requestNotificationsIfAlarmsEnabled() + OnboardingContainerView(onClose: { showOnboarding = false }) } } diff --git a/LoopFollow/Helpers/Telemetry.swift b/LoopFollow/Helpers/Telemetry.swift index afd90117f..2b3ad5c3e 100644 --- a/LoopFollow/Helpers/Telemetry.swift +++ b/LoopFollow/Helpers/Telemetry.swift @@ -6,24 +6,141 @@ import Foundation import SwiftUI import UIKit +// MARK: - TelemetrySendGate + +/// Serializes send decisions across the launch, foreground and scheduler +/// triggers. Actors are reentrant, so `isSending` stays set while the +/// operation is suspended on network work. +actor TelemetrySendGate { + private var isSending = false + + func runIfEligible( + now: Date, + lastSentAt: Date?, + lastAttemptAt: Date?, + minimumInterval: TimeInterval, + retryInterval: TimeInterval, + force: Bool = false, + operation: () async -> Bool, + onAttempt: () async -> Void = {}, + onSuccess: () async -> Void = {} + ) async -> Bool { + guard !isSending else { return false } + guard force || Self.isOverdue(now: now, lastSentAt: lastSentAt, minimumInterval: minimumInterval) else { + return false + } + guard Self.isOverdue(now: now, lastSentAt: lastAttemptAt, minimumInterval: retryInterval) else { + return false + } + + isSending = true + defer { isSending = false } + await onAttempt() + let succeeded = await operation() + if succeeded { + await onSuccess() + } + return succeeded + } + + nonisolated static func isOverdue(now: Date, lastSentAt: Date?, minimumInterval: TimeInterval) -> Bool { + guard let lastSentAt else { return true } + return now.timeIntervalSince(lastSentAt) >= minimumInterval + } +} + // MARK: - TelemetryClient +/// Opt-out anonymous usage check-in. Sends the payload from `buildPayload()` +/// at most once every 24 hours, plus once after a new build is installed. +/// Installs that turned sharing off send only an empty daily `/anonymous` GET +/// carrying the app version, build, install identifier, trigger reason and +/// prior failure category, so active installs per version can be counted. final class TelemetryClient { static let shared = TelemetryClient() - private static let endpoint = URL(string: "https://lf.bjorkert.se/api/telemetry/checkin")! + enum SendReason: String { + case background + case buildChange = "build_change" + case coldLaunch = "cold_launch" + case foreground + case optIn = "opt_in" + case timer + } + + private static let productionBaseURL = URL(string: "https://lf.bjorkert.se/api/telemetry/")! private static let salt = "lf-telemetry" private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60 private static let dailyInterval: TimeInterval = 24 * 60 * 60 + private static let retryInterval: TimeInterval = 60 * 60 + + /// Static write token, committed in source. The LoopFollow repo is public, + /// so this string is public too. The backend treats it as a "front door + /// sign" rather than a secret: TLS, an NGINX rate limit (10 req/min/IP), + /// strict validation, and insert-only storage bound any abuse to + /// harmless row inserts. + private static let writeToken = "RsEDJ8RoOs7HHZ_XGOdI1sY3Yuv6iPnRRk7tg-NlCAg" + + private let sendGate = TelemetrySendGate() + private var foregroundObserver: NSObjectProtocol? private init() {} - /// Records a cold launch in a sliding 7-day window of timestamps. Called - /// from AppDelegate.didFinishLaunchingWithOptions on every process start - /// (foreground or background). The count of entries in the window is sent - /// as `coldLaunches7d` in each ping, giving a "how often is iOS recycling - /// or killing this process" signal that's directly comparable across - /// pings regardless of the cadence between them. + /// Debug builds honor `LF_TELEMETRY_BASE_URL` from the launch environment, + /// which points simulator runs at a local server. + private static var baseURL: URL { + #if DEBUG + if let override = ProcessInfo.processInfo.environment["LF_TELEMETRY_BASE_URL"], + let url = URL(string: override.hasSuffix("/") ? override : override + "/") + { + return url + } + #endif + return productionBaseURL + } + + // MARK: - Lifecycle + + /// Called once per process from AppDelegate after storage is readable. + /// Records the cold launch, sends immediately when the build changed since + /// the last successful full send, then runs the daily overdue check and + /// arms the scheduler. Foreground transitions repeat the overdue check. + func start() { + _ = installId() + recordColdLaunch() + + if foregroundObserver == nil { + foregroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.checkAndReschedule(reason: .foreground) + } + } + + Task.detached { + await self.sendIfOverdue(reason: .coldLaunch) + await MainActor.run { self.scheduleRecurring() } + } + } + + /// Called when the user turns sharing back on in Settings, so the first + /// full payload arrives at once rather than after the next daily check. + func sharingEnabled() { + Task.detached { + await self.sendNow(reason: .optIn) + await MainActor.run { self.scheduleRecurring() } + } + } + + // MARK: - Cold launches + + /// Records a cold launch in a sliding 7-day window of timestamps. The count + /// of entries in the window is sent as `coldLaunches7d` in each ping, + /// giving a "how often is iOS recycling or killing this process" signal + /// that's directly comparable across pings regardless of the cadence + /// between them. func recordColdLaunch(now: Date = Date()) { let cutoff = now.addingTimeInterval(-Self.weeklyInterval) var recent = Storage.shared.telemetryColdLaunchTimes.value @@ -32,87 +149,202 @@ final class TelemetryClient { Storage.shared.telemetryColdLaunchTimes.value = recent } - /// Static write token, committed in source. The LoopFollow repo is public, - /// so this string is public too. The backend treats it as a "front door - /// sign" rather than a secret: TLS, NGINX rate limit (60 req/min/IP), - /// strict schema validation, and an insert+find-only MongoDB role bound - /// any abuse to harmless duplicate-row inserts. - private static let writeToken = "RsEDJ8RoOs7HHZ_XGOdI1sY3Yuv6iPnRRk7tg-NlCAg" + // MARK: - Identity + + /// Stable per-install UUID, generated on first use. IDFV resets when every + /// app from this developer is removed at once; this survives that and is + /// wiped only by deleting this LoopFollow instance. + func installId() -> String { + let storage = Storage.shared + if !storage.telemetryInstallId.value.isEmpty { + return storage.telemetryInstallId.value + } + let new = UUID().uuidString + storage.telemetryInstallId.value = new + return new + } + + private static var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + } + + private static var commitSha: String { + BuildDetails.default.commitSha ?? "" + } + + private static var buildDate: String? { + guard let date = BuildDetails.default.buildDateString, date.count >= 10 else { return nil } + return String(date.prefix(10)) + } + + /// Version, build date and commit: the fields the server keys a build on, + /// so every build it stores as new is also announced as new. + private static var buildIdentity: String { + "\(appVersion)|\(buildDate ?? "")|\(commitSha)" + } - /// True when the running build's commit SHA differs from the SHA recorded - /// at the last successful send. Used at startup to fire one immediate - /// ping after an app update — the regular 24h scheduler can't tell that - /// the build changed and would otherwise wait out the previous interval. - func buildShaChangedSinceLastSend() -> Bool { - let currentSha = BuildDetails.default.commitSha ?? "" - return Storage.shared.telemetryLastSentSha.value != currentSha - } - - /// Wires telemetry into TaskScheduler. Called once on app start (from - /// AppDelegate.didFinishLaunchingWithOptions) and again after each tick. - /// First run is computed from `telemetryLastSentAt`: a relaunch 6h after - /// the previous send waits 18h; a relaunch after 25h fires on the next - /// timer tick (TaskScheduler treats a past nextRun as "fire soon"). Each - /// fired tick reschedules itself for +24h, giving the steady-state - /// cadence while the app keeps running. - /// - /// Bails out without scheduling if the user hasn't decided on consent - /// yet or has opted out — there's nothing for the timer to do, and - /// scheduling for "now" with `lastSentAt` still nil would tight-loop - /// (fire → maybeSend bails → reschedule → fire …). + /// True until a send of either kind succeeds on the running build. + func buildChangedSinceLastSend() -> Bool { + Storage.shared.telemetryLastSentBuild.value != Self.buildIdentity + } + + // MARK: - Cadence + + /// Arms the scheduler for the next moment a send can be due: at once for an + /// unreported build, otherwise 24h after the last successful send of the + /// active kind, and never sooner than an hour after the last attempt. Each + /// tick runs the overdue check and re-arms. func scheduleRecurring() { let storage = Storage.shared - guard storage.telemetryConsentDecisionMade.value, - storage.telemetryEnabled.value - else { - return - } - - let nextRun: Date - if let last = storage.telemetryLastSentAt.value { - nextRun = last.addingTimeInterval(Self.dailyInterval) - } else { - // Consent given but we've never landed a successful send - // (network down at first launch, server hiccup, etc). Retry in - // a minute — bounded so a persistently failing send doesn't - // turn into a busy loop. - nextRun = Date().addingTimeInterval(60) - } - - TaskScheduler.shared.scheduleTask(id: .telemetry, nextRun: nextRun) { - Task.detached { - await TelemetryClient.shared.maybeSend() - TelemetryClient.shared.scheduleRecurring() + let lastSent = storage.telemetryEnabled.value + ? storage.telemetryLastSentAt.value + : storage.telemetryAnonymousLastSentAt.value + let now = Date() + + var nextRun = buildChangedSinceLastSend() + ? now + : lastSent.map { $0.addingTimeInterval(Self.dailyInterval) } ?? now + if let lastAttempt = storage.telemetryLastAttemptAt.value { + nextRun = max(nextRun, lastAttempt.addingTimeInterval(Self.retryInterval)) + } + nextRun = max(nextRun, now.addingTimeInterval(60)) + + TaskScheduler.shared.scheduleTask(id: .telemetry, nextRun: nextRun) { [weak self] in + let reason: SendReason = UIApplication.shared.applicationState == .background ? .background : .timer + self?.checkAndReschedule(reason: reason) + } + } + + /// A background tick runs under its own UIKit background task, so iOS + /// lets the request finish before suspending the app. + private func checkAndReschedule(reason: SendReason) { + let backgroundTask = BackgroundTask() + if reason == .background { + backgroundTask.begin(name: "Telemetry Check-In") + } + Task.detached { + await self.sendIfOverdue(reason: reason) + await MainActor.run { + self.scheduleRecurring() + backgroundTask.end() } } } - /// Single entry point used by all callers (scheduler tick, consent-yes, - /// startup SHA-change). Gated only by consent + opt-in toggle; *when* to - /// send is the caller's decision (the scheduler handles the 24h cadence - /// by setting `nextRun`; startup handles the SHA-change shortcut). - func maybeSend() async { + /// One UIKit background task, ended exactly once by whichever comes + /// first: the work finishing or the expiration handler. Main thread only. + private final class BackgroundTask { + private var id = UIBackgroundTaskIdentifier.invalid + + func begin(name: String) { + id = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in + self?.end() + } + } + + func end() { + guard id != .invalid else { return } + UIApplication.shared.endBackgroundTask(id) + id = .invalid + } + } + + /// Entry point shared by every trigger. Sends the full payload when + /// sharing is on and the anonymous heartbeat when it is off: at once when + /// the running build has not been reported yet, otherwise once the daily + /// interval has passed. Storage is read and written on the main actor; + /// only the request runs off it. + @discardableResult func sendIfOverdue(reason: SendReason, now: Date = Date()) async -> Bool { + let (sharing, lastSentAt, lastAttemptAt, buildChanged) = await MainActor.run { + let storage = Storage.shared + let sharing = storage.telemetryEnabled.value + return ( + sharing, + sharing ? storage.telemetryLastSentAt.value : storage.telemetryAnonymousLastSentAt.value, + storage.telemetryLastAttemptAt.value, + self.buildChangedSinceLastSend() + ) + } + let sendReason: SendReason = buildChanged ? .buildChange : reason + + return await sendGate.runIfEligible( + now: now, + lastSentAt: lastSentAt, + lastAttemptAt: lastAttemptAt, + minimumInterval: Self.dailyInterval, + retryInterval: Self.retryInterval, + force: buildChanged, + operation: { + sharing ? await self.send(reason: sendReason) : await self.sendAnonymous(reason: sendReason) + }, + onAttempt: { await MainActor.run { Storage.shared.telemetryLastAttemptAt.value = now } }, + onSuccess: { + await MainActor.run { + if sharing { + self.recordSuccessfulSend() + } else { + self.recordSuccessfulAnonymousSend() + } + } + } + ) + } + + /// Full send regardless of the daily window, for a build change or a + /// fresh opt-in. Still subject to the one-hour retry delay. + @discardableResult func sendNow(reason: SendReason) async -> Bool { + let (sharing, lastSentAt, lastAttemptAt) = await MainActor.run { + let storage = Storage.shared + return (storage.telemetryEnabled.value, storage.telemetryLastSentAt.value, storage.telemetryLastAttemptAt.value) + } + guard sharing else { return false } + let now = Date() + return await sendGate.runIfEligible( + now: now, + lastSentAt: lastSentAt, + lastAttemptAt: lastAttemptAt, + minimumInterval: Self.dailyInterval, + retryInterval: Self.retryInterval, + force: true, + operation: { await self.send(reason: reason) }, + onAttempt: { await MainActor.run { Storage.shared.telemetryLastAttemptAt.value = now } }, + onSuccess: { await MainActor.run { self.recordSuccessfulSend() } } + ) + } + + private func recordSuccessfulSend() { let storage = Storage.shared - guard storage.telemetryConsentDecisionMade.value else { return } - guard storage.telemetryEnabled.value else { return } - await send() + storage.telemetryLastSentAt.value = Date() + storage.telemetryLastSentBuild.value = Self.buildIdentity + storage.telemetryLastAttemptAt.value = nil } + private func recordSuccessfulAnonymousSend() { + let storage = Storage.shared + storage.telemetryAnonymousLastSentAt.value = Date() + storage.telemetryLastSentBuild.value = Self.buildIdentity + storage.telemetryLastAttemptAt.value = nil + } + + // MARK: - Payload + /// The exact payload that would be POSTed right now. Pure function: useful /// both for sending and for the "What's sent" preview UI. func buildPayload() -> [String: Any] { let storage = Storage.shared - let info = Bundle.main.infoDictionary ?? [:] let bd = BuildDetails.default var payload: [String: Any] = [:] - if let v = info["CFBundleShortVersionString"] as? String { payload["appVersion"] = v } + payload["appVersion"] = Self.appVersion + if !Self.commitSha.isEmpty { + payload["commitSha"] = Self.commitSha + } // Date-only (YYYY-MM-DD) prefix of the ISO8601 build date. Time is // dropped to keep the payload to a low-resolution build identifier. - if let date = bd.buildDateString, date.count >= 10 { - payload["buildDate"] = String(date.prefix(10)) + if let buildDate = Self.buildDate { + payload["buildDate"] = buildDate } // Only signal we can actually verify: receipt-based TestFlight check. @@ -122,6 +354,7 @@ final class TelemetryClient { payload["isTestFlight"] = bd.isTestFlightBuild() payload["instance"] = AppConstants.appInstanceId + payload["installId"] = installId() if let idfv = UIDevice.current.identifierForVendor?.uuidString { payload["idfv"] = idfv @@ -159,41 +392,114 @@ final class TelemetryClient { return payload } - /// Build payload, POST it, update last-sent state on 2xx. Fire-and-forget; - /// errors are logged at debug level only and never surfaced to the UI. - func send() async { - let storage = Storage.shared - let payload = buildPayload() - guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else { - LogManager.shared.log(category: .telemetry, message: "skip send: payload not JSON-serializable", isDebug: true) - return + /// Everything the anonymous heartbeat carries, for the "What's sent" + /// preview. The trigger varies per request. + func anonymousPreview() -> [String: Any] { + var preview: [String: Any] = [ + "appVersion": Self.appVersion, + "installId": installId(), + "reason": "what triggered the check-in, e.g. cold_launch", + ] + if !Self.commitSha.isEmpty { + preview["commitSha"] = Self.commitSha + } + if let buildDate = Self.buildDate { + preview["buildDate"] = buildDate } + if let failure = Storage.shared.telemetryLastFailureReason.value { + preview["lastFailureReason"] = failure + } + return preview + } - var request = URLRequest(url: Self.endpoint) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(Self.writeToken)", forHTTPHeaderField: "Authorization") - request.httpBody = body + // MARK: - Send + + /// URL for `path` with the trigger reason and the previous attempt's + /// failure category as query items. + static func requestURL(baseURL: URL, path: String, reason: SendReason, lastFailureReason: String?) -> URL? { + guard var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) else { + return nil + } + var items = [URLQueryItem(name: "reason", value: reason.rawValue)] + if let lastFailureReason, !lastFailureReason.isEmpty { + items.append(URLQueryItem(name: "lastFailureReason", value: lastFailureReason)) + } + components.queryItems = items + return components.url + } + + private func request(path: String, method: String, reason: SendReason) -> URLRequest? { + guard let url = Self.requestURL( + baseURL: Self.baseURL, + path: path, + reason: reason, + lastFailureReason: Storage.shared.telemetryLastFailureReason.value + ) else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = method request.timeoutInterval = 15 + request.setValue("Bearer \(Self.writeToken)", forHTTPHeaderField: "Authorization") + request.setValue(Self.appVersion, forHTTPHeaderField: "X-LoopFollow-Version") + request.setValue(installId(), forHTTPHeaderField: "X-LoopFollow-InstallId") + if !Self.commitSha.isEmpty { + request.setValue(Self.commitSha, forHTTPHeaderField: "X-LoopFollow-CommitSha") + } + if let buildDate = Self.buildDate { + request.setValue(buildDate, forHTTPHeaderField: "X-LoopFollow-BuildDate") + } + #if targetEnvironment(simulator) + request.setValue("true", forHTTPHeaderField: "X-LoopFollow-Simulator") + #endif + return request + } + /// Performs `request` and returns success on 2xx. Any failure is stored + /// as a short category that the next request reports. + private func perform(_ request: URLRequest?, label: String) async -> Bool { + let failureReason: String? do { - let (_, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - LogManager.shared.log(category: .telemetry, message: "send: non-HTTP response", isDebug: true) - return - } - if (200 ..< 300).contains(http.statusCode) { - let now = Date() - let sha = BuildDetails.default.commitSha ?? "" - storage.telemetryLastSentAt.value = now - storage.telemetryLastSentSha.value = sha - LogManager.shared.log(category: .telemetry, message: "send ok status=\(http.statusCode)", isDebug: true) - } else { - LogManager.shared.log(category: .telemetry, message: "send non-2xx status=\(http.statusCode)", isDebug: true) - } + failureReason = try await outcome(of: request, label: label) } catch { - LogManager.shared.log(category: .telemetry, message: "send error: \(error.localizedDescription)", isDebug: true) + LogManager.shared.log(category: .telemetry, message: "\(label) error: \(error.localizedDescription)", isDebug: true) + failureReason = "\(label)_request_failed" + } + await MainActor.run { Storage.shared.telemetryLastFailureReason.value = failureReason } + return failureReason == nil + } + + /// Nil on 2xx, otherwise the failure category. + private func outcome(of request: URLRequest?, label: String) async throws -> String? { + guard let request else { return "\(label)_request_url_failed" } + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + LogManager.shared.log(category: .telemetry, message: "\(label): non-HTTP response", isDebug: true) + return "\(label)_non_http_response" + } + guard (200 ..< 300).contains(http.statusCode) else { + LogManager.shared.log(category: .telemetry, message: "\(label) status=\(http.statusCode)", isDebug: true) + return "\(label)_http_\(http.statusCode)" } + LogManager.shared.log(category: .telemetry, message: "\(label) ok status=\(http.statusCode)", isDebug: true) + return nil + } + + private func send(reason: SendReason) async -> Bool { + let request: URLRequest? = await MainActor.run { + guard let body = try? JSONSerialization.data(withJSONObject: self.buildPayload(), options: []) else { + return nil + } + var request = self.request(path: "checkin", method: "POST", reason: reason) + request?.setValue("application/json", forHTTPHeaderField: "Content-Type") + request?.httpBody = body + return request + } + return await perform(request, label: "checkin") + } + + private func sendAnonymous(reason: SendReason) async -> Bool { + let request = await MainActor.run { self.request(path: "anonymous", method: "GET", reason: reason) } + return await perform(request, label: "anonymous") } // MARK: - Helpers @@ -244,11 +550,14 @@ final class TelemetryClient { /// consent sheet's "See exactly what's sent" button. struct TelemetryPreviewView: View { @State private var jsonText: String = "" + @State private var sharing = Storage.shared.telemetryEnabled.value var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { - Text("Below is the exact JSON object that LoopFollow would send to lf.bjorkert.se right now.") + Text(sharing + ? "Below is the exact JSON object that LoopFollow would send to lf.bjorkert.se right now." + : "Usage stats are off. LoopFollow sends only this minimal check-in to lf.bjorkert.se, once a day and after installing a new build.") .font(.subheadline) .foregroundColor(.secondary) .padding(.bottom, 4) @@ -272,11 +581,14 @@ struct TelemetryPreviewView: View { } .navigationTitle("What's sent") .navigationBarTitleDisplayMode(.inline) - .onAppear { jsonText = Self.renderPayload() } + .onAppear { + sharing = Storage.shared.telemetryEnabled.value + jsonText = Self.renderPayload(sharing: sharing) + } } - private static func renderPayload() -> String { - let payload = TelemetryClient.shared.buildPayload() + private static func renderPayload(sharing: Bool) -> String { + let payload = sharing ? TelemetryClient.shared.buildPayload() : TelemetryClient.shared.anonymousPreview() guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]), let text = String(data: data, encoding: .utf8) else { return "Unable to render payload." } @@ -295,13 +607,13 @@ struct TelemetryPrivacyView: View { Group { Text("Endpoint") .font(.headline) - 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.") + Text("Usage stats are on by default. Once a day, and after installing 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.") } Group { Text("What is sent") .font(.headline) - 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.") + Text("App version, build commit and build date, whether this is a TestFlight build, the install instance number, a random install ID created by this app, 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), the platform (iPhone, iPad or Mac), the iOS version, and how many times the app was started from scratch in the last 7 days. 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.") } Group { @@ -313,13 +625,13 @@ struct TelemetryPrivacyView: View { Group { Text("Frequency") .font(.headline) - 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.") + Text("Once every 24 hours, plus once after installing a new build. Each request also says what triggered it (for example app launch or new build) and, if the previous attempt failed, a short error category. The check runs when the app launches, when it comes to the foreground, and while it is refreshing in the background.") } Group { Text("Opt out") .font(.headline) - Text("Use the Send anonymous usage stats toggle above. Turning it off is immediate and persistent.") + Text("Use the Send anonymous usage stats toggle above. Turning it off is immediate and persistent. After that, the app sends only a minimal check-in, once a day and after installing a new build, so the developers can count how many installs run each version: the app version, build commit and build date, the random install ID, and the trigger and error category described above. It contains no device, settings, or health information.") } Group { @@ -334,67 +646,3 @@ struct TelemetryPrivacyView: View { .navigationBarTitleDisplayMode(.inline) } } - -// MARK: - TelemetryConsentView - -/// One-time prompt shown the first time the app foregrounds after install -/// or after an update from a pre-telemetry version. -struct TelemetryConsentView: View { - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationView { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - 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.") - - Text("You can change this any time in Settings → General → Diagnostics.") - .font(.subheadline) - .foregroundColor(.secondary) - - NavigationLink { - TelemetryPreviewView() - } label: { - Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass") - } - .padding(.top, 4) - } - .padding() - } - .navigationTitle("Help us help you!") - .navigationBarTitleDisplayMode(.inline) - .safeAreaInset(edge: .bottom) { - VStack(spacing: 8) { - Button { - Storage.shared.telemetryEnabled.value = true - Storage.shared.telemetryConsentDecisionMade.value = true - // Fire the inaugural ping immediately, then start the - // 24h scheduled cadence ticking from that send. - Task.detached { - await TelemetryClient.shared.maybeSend() - TelemetryClient.shared.scheduleRecurring() - } - dismiss() - } label: { - Text("Yes, send anonymous stats") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - - Button { - Storage.shared.telemetryEnabled.value = false - Storage.shared.telemetryConsentDecisionMade.value = true - dismiss() - } label: { - Text("No thanks") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - } - .padding(.horizontal) - .padding(.bottom, 12) - .background(.bar) - } - } - } -} diff --git a/LoopFollow/Onboarding/OnboardingContainerView.swift b/LoopFollow/Onboarding/OnboardingContainerView.swift index 35ca8dc73..a772da8cc 100644 --- a/LoopFollow/Onboarding/OnboardingContainerView.swift +++ b/LoopFollow/Onboarding/OnboardingContainerView.swift @@ -82,8 +82,6 @@ struct OnboardingContainerView: View { TabOrderStepView() case .notifications: NotificationsStepView(viewModel: viewModel) - case .telemetry: - TelemetryStepView(viewModel: viewModel) case .completion: CompletionStepView(viewModel: viewModel) } diff --git a/LoopFollow/Onboarding/OnboardingStep.swift b/LoopFollow/Onboarding/OnboardingStep.swift index 0ae033ef4..e1166a39e 100644 --- a/LoopFollow/Onboarding/OnboardingStep.swift +++ b/LoopFollow/Onboarding/OnboardingStep.swift @@ -24,7 +24,6 @@ enum OnboardingStep: CaseIterable, Hashable { case alarms case tabOrder case notifications - case telemetry case completion /// Steps that show the progress bar + phase label + Skip at the top. The @@ -40,7 +39,7 @@ enum OnboardingStep: CaseIterable, Hashable { /// Steps that use the shared Back / Continue footer. Phases that contain their /// own internal pages or custom primary buttons (overview, connect, units, - /// alarms, notifications, telemetry) supply their own footer instead, as do the + /// alarms, notifications) supply their own footer instead, as do the /// full-bleed welcome and completion screens. var usesSharedFooter: Bool { switch self { @@ -63,7 +62,6 @@ enum OnboardingStep: CaseIterable, Hashable { case .alarms: return "Alarms" case .tabOrder: return "Tabs" case .notifications: return "Notifications" - case .telemetry: return "Privacy" } } } diff --git a/LoopFollow/Onboarding/OnboardingViewModel.swift b/LoopFollow/Onboarding/OnboardingViewModel.swift index 5cd894857..7bce44128 100644 --- a/LoopFollow/Onboarding/OnboardingViewModel.swift +++ b/LoopFollow/Onboarding/OnboardingViewModel.swift @@ -70,11 +70,6 @@ final class OnboardingViewModel: ObservableObject { private let onClose: () -> Void private var cancellables = Set() - /// Whether to show the in-flow telemetry consent phase. Captured once at - /// launch so the decision the phase records doesn't remove it from under the - /// navigation while the user is still on it. - private let includeTelemetryStep: Bool - /// Alarm types the user already has, captured at launch. Onboarding only /// offers recommended alarms whose type the user doesn't already own, so a /// returning user is helped to add new ones without touching their existing @@ -83,7 +78,6 @@ final class OnboardingViewModel: ObservableObject { init(onClose: @escaping () -> Void) { self.onClose = onClose - includeTelemetryStep = !Storage.shared.telemetryConsentDecisionMade.value existingAlarmTypes = Set(Storage.shared.alarms.value.map(\.type)) let hasNightscout = !Storage.shared.url.value.isEmpty let hasDexcom = !Storage.shared.shareUserName.value.isEmpty @@ -122,7 +116,7 @@ final class OnboardingViewModel: ObservableObject { var canProceed: Bool { switch step { case .welcome, .overview, .units, .generalAlarms, .alarms, - .tabOrder, .notifications, .telemetry, .completion: + .tabOrder, .notifications, .completion: return true case .dataSource: return dataSource != nil @@ -170,8 +164,8 @@ final class OnboardingViewModel: ObservableObject { var steps: [OnboardingStep] = [.welcome, .overview] // Already set up and nothing to add — the overview is the last stop, with - // a "Done" that finishes. Notification/telemetry consent is still handled - // after dismissal, the same as when skipping. + // a "Done" that finishes. The notification request is still handled after + // dismissal, the same as when skipping. if hasNothingToSetUp { return steps } @@ -190,9 +184,6 @@ final class OnboardingViewModel: ObservableObject { if notificationsUndecided { steps.append(.notifications) } - if includeTelemetryStep { - steps.append(.telemetry) - } steps.append(.completion) return steps } diff --git a/LoopFollow/Onboarding/Steps/TelemetryStepView.swift b/LoopFollow/Onboarding/Steps/TelemetryStepView.swift deleted file mode 100644 index 02bf5d70c..000000000 --- a/LoopFollow/Onboarding/Steps/TelemetryStepView.swift +++ /dev/null @@ -1,79 +0,0 @@ -// LoopFollow -// TelemetryStepView.swift - -import SwiftUI - -/// In-flow telemetry consent. Mirrors `TelemetryConsentView` but records the -/// decision and continues the wizard instead of dismissing a sheet. -struct TelemetryStepView: View { - @ObservedObject var viewModel: OnboardingViewModel - @State private var showPreview = false - - var body: some View { - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: 16) { - Image(systemName: "chart.bar.doc.horizontal") - .font(.system(size: 44, weight: .semibold)) - .foregroundStyle(Color.accentColor) - - Text("Help us help you") - .font(.title2.weight(.bold)) - - 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.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - - Button { showPreview = true } label: { - Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass") - .font(.subheadline) - } - - Text("You can change this any time in Settings → General → Diagnostics.") - .font(.subheadline) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - .padding(.top, 8) - - Spacer() - - VStack(spacing: 12) { - Button { decide(true) } label: { - Text("Yes, send anonymous stats") - .font(.body.weight(.semibold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 4) - } - .buttonStyle(.borderedProminent) - - Button { decide(false) } label: { - Text("No thanks") - .font(.body.weight(.medium)) - } - } - .padding(.horizontal, 24) - .padding(.bottom, 24) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .sheet(isPresented: $showPreview) { - NavigationStack { - TelemetryPreviewView() - } - } - } - - private func decide(_ enabled: Bool) { - Storage.shared.telemetryEnabled.value = enabled - Storage.shared.telemetryConsentDecisionMade.value = true - if enabled { - // Fire the inaugural ping immediately, then start the 24h cadence. - Task.detached { - await TelemetryClient.shared.maybeSend() - TelemetryClient.shared.scheduleRecurring() - } - } - viewModel.advance() - } -} diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 073d9c72d..e2451dc4c 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -156,6 +156,8 @@ struct GeneralSettingsView: View { Toggle("Send anonymous usage stats", isOn: $telemetryEnabled.value) .onChange(of: telemetryEnabled.value) { newValue in if newValue { + TelemetryClient.shared.sharingEnabled() + } else { TelemetryClient.shared.scheduleRecurring() } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4f533740a..c790cf3ac 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -208,10 +208,17 @@ class Storage { // See LoopFollow/Helpers/Telemetry.swift. + // Sharing is on unless the user turned it off; a stored `false` is always an + // explicit choice, because the default is never written. var telemetryEnabled = StorageValue(key: "telemetryEnabled", defaultValue: true) + // Written by builds that asked for consent; read only as a storage-readable probe. var telemetryConsentDecisionMade = StorageValue(key: "telemetryConsentDecisionMade", defaultValue: false) + var telemetryInstallId = StorageValue(key: "telemetryInstallId", defaultValue: "") var telemetryLastSentAt = StorageValue(key: "telemetryLastSentAt", defaultValue: nil) - var telemetryLastSentSha = StorageValue(key: "telemetryLastSentSha", defaultValue: "") + var telemetryLastSentBuild = StorageValue(key: "telemetryLastSentBuild", defaultValue: "") + var telemetryAnonymousLastSentAt = StorageValue(key: "telemetryAnonymousLastSentAt", defaultValue: nil) + var telemetryLastAttemptAt = StorageValue(key: "telemetryLastAttemptAt", defaultValue: nil) + var telemetryLastFailureReason = StorageValue(key: "telemetryLastFailureReason", defaultValue: nil) // Sliding 7-day window of cold-launch timestamps. var telemetryColdLaunchTimes = StorageValue<[Date]>(key: "telemetryColdLaunchTimes", defaultValue: []) diff --git a/Tests/TelemetrySendGateTests.swift b/Tests/TelemetrySendGateTests.swift new file mode 100644 index 000000000..098a06b3c --- /dev/null +++ b/Tests/TelemetrySendGateTests.swift @@ -0,0 +1,117 @@ +// LoopFollow +// TelemetrySendGateTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct TelemetrySendGateTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + private let day: TimeInterval = 24 * 60 * 60 + private let hour: TimeInterval = 60 * 60 + + private func run( + _ gate: TelemetrySendGate, + lastSentAt: Date?, + lastAttemptAt: Date? = nil, + force: Bool = false, + succeeds: Bool = true + ) async -> (ran: Bool, result: Bool) { + var ran = false + let result = await gate.runIfEligible( + now: now, + lastSentAt: lastSentAt, + lastAttemptAt: lastAttemptAt, + minimumInterval: day, + retryInterval: hour, + force: force, + operation: { + ran = true + return succeeds + } + ) + return (ran, result) + } + + @Test("never sent is overdue") + func neverSent() async { + let outcome = await run(TelemetrySendGate(), lastSentAt: nil) + #expect(outcome.ran) + #expect(outcome.result) + } + + @Test("a send within the daily interval is skipped") + func withinInterval() async { + let outcome = await run(TelemetrySendGate(), lastSentAt: now.addingTimeInterval(-day + 60)) + #expect(!outcome.ran) + } + + @Test("a send after the daily interval runs") + func afterInterval() async { + let outcome = await run(TelemetrySendGate(), lastSentAt: now.addingTimeInterval(-day)) + #expect(outcome.ran) + } + + @Test("force runs inside the daily interval") + func forceInsideInterval() async { + let outcome = await run(TelemetrySendGate(), lastSentAt: now.addingTimeInterval(-60), force: true) + #expect(outcome.ran) + } + + @Test("an attempt within the last hour blocks even a forced send") + func retryBackoff() async { + let outcome = await run( + TelemetrySendGate(), + lastSentAt: now.addingTimeInterval(-2 * day), + lastAttemptAt: now.addingTimeInterval(-hour + 60), + force: true + ) + #expect(!outcome.ran) + } + + @Test("an attempt over an hour ago allows a retry") + func retryAfterBackoff() async { + let outcome = await run( + TelemetrySendGate(), + lastSentAt: now.addingTimeInterval(-2 * day), + lastAttemptAt: now.addingTimeInterval(-hour) + ) + #expect(outcome.ran) + } + + @Test("callbacks report attempt and success") + func callbacks() async { + let gate = TelemetrySendGate() + var attempts = 0 + var successes = 0 + for succeeds in [false, true] { + _ = await gate.runIfEligible( + now: now, + lastSentAt: nil, + lastAttemptAt: nil, + minimumInterval: day, + retryInterval: hour, + operation: { succeeds }, + onAttempt: { attempts += 1 }, + onSuccess: { successes += 1 } + ) + } + #expect(attempts == 2) + #expect(successes == 1) + } + + @Test("request URL carries reason and failure category") + func requestURL() throws { + let base = try #require(URL(string: "https://example.org/api/telemetry/")) + let url = try #require(TelemetryClient.requestURL( + baseURL: base, + path: "anonymous", + reason: .buildChange, + lastFailureReason: "checkin_http_500" + )) + #expect(url.absoluteString == "https://example.org/api/telemetry/anonymous?reason=build_change&lastFailureReason=checkin_http_500") + + let plain = try #require(TelemetryClient.requestURL(baseURL: base, path: "checkin", reason: .timer, lastFailureReason: nil)) + #expect(plain.absoluteString == "https://example.org/api/telemetry/checkin?reason=timer") + } +} From da6d08f612f20e794a0152921dbad289b9ba3d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 6 Nov 2026 20:00:46 +0100 Subject: [PATCH 2/2] Send nothing when usage stats are off --- LoopFollow/Helpers/Telemetry.swift | 116 ++++++++--------------------- LoopFollow/Storage/Storage.swift | 1 - Tests/TelemetrySendGateTests.swift | 4 +- 3 files changed, 31 insertions(+), 90 deletions(-) diff --git a/LoopFollow/Helpers/Telemetry.swift b/LoopFollow/Helpers/Telemetry.swift index 2b3ad5c3e..a938c499d 100644 --- a/LoopFollow/Helpers/Telemetry.swift +++ b/LoopFollow/Helpers/Telemetry.swift @@ -53,9 +53,7 @@ actor TelemetrySendGate { /// Opt-out anonymous usage check-in. Sends the payload from `buildPayload()` /// at most once every 24 hours, plus once after a new build is installed. -/// Installs that turned sharing off send only an empty daily `/anonymous` GET -/// carrying the app version, build, install identifier, trigger reason and -/// prior failure category, so active installs per version can be counted. +/// Installs that turned sharing off send nothing. final class TelemetryClient { static let shared = TelemetryClient() @@ -183,7 +181,7 @@ final class TelemetryClient { "\(appVersion)|\(buildDate ?? "")|\(commitSha)" } - /// True until a send of either kind succeeds on the running build. + /// True until a send succeeds on the running build. func buildChangedSinceLastSend() -> Bool { Storage.shared.telemetryLastSentBuild.value != Self.buildIdentity } @@ -191,19 +189,21 @@ final class TelemetryClient { // MARK: - Cadence /// Arms the scheduler for the next moment a send can be due: at once for an - /// unreported build, otherwise 24h after the last successful send of the - /// active kind, and never sooner than an hour after the last attempt. Each - /// tick runs the overdue check and re-arms. + /// unreported build, otherwise 24h after the last successful send, and + /// never sooner than an hour after the last attempt. With sharing off the + /// tick is parked a day out. Each tick runs the overdue check and re-arms. func scheduleRecurring() { let storage = Storage.shared - let lastSent = storage.telemetryEnabled.value - ? storage.telemetryLastSentAt.value - : storage.telemetryAnonymousLastSentAt.value let now = Date() - var nextRun = buildChangedSinceLastSend() - ? now - : lastSent.map { $0.addingTimeInterval(Self.dailyInterval) } ?? now + var nextRun: Date + if !storage.telemetryEnabled.value { + nextRun = now.addingTimeInterval(Self.dailyInterval) + } else if buildChangedSinceLastSend() { + nextRun = now + } else { + nextRun = storage.telemetryLastSentAt.value.map { $0.addingTimeInterval(Self.dailyInterval) } ?? now + } if let lastAttempt = storage.telemetryLastAttemptAt.value { nextRun = max(nextRun, lastAttempt.addingTimeInterval(Self.retryInterval)) } @@ -249,22 +249,21 @@ final class TelemetryClient { } } - /// Entry point shared by every trigger. Sends the full payload when - /// sharing is on and the anonymous heartbeat when it is off: at once when - /// the running build has not been reported yet, otherwise once the daily - /// interval has passed. Storage is read and written on the main actor; - /// only the request runs off it. + /// Entry point shared by every trigger. With sharing on, sends the payload + /// at once when the running build has not been reported yet, otherwise once + /// the daily interval has passed. Storage is read and written on the main + /// actor; only the request runs off it. @discardableResult func sendIfOverdue(reason: SendReason, now: Date = Date()) async -> Bool { let (sharing, lastSentAt, lastAttemptAt, buildChanged) = await MainActor.run { let storage = Storage.shared - let sharing = storage.telemetryEnabled.value return ( - sharing, - sharing ? storage.telemetryLastSentAt.value : storage.telemetryAnonymousLastSentAt.value, + storage.telemetryEnabled.value, + storage.telemetryLastSentAt.value, storage.telemetryLastAttemptAt.value, self.buildChangedSinceLastSend() ) } + guard sharing else { return false } let sendReason: SendReason = buildChanged ? .buildChange : reason return await sendGate.runIfEligible( @@ -274,19 +273,9 @@ final class TelemetryClient { minimumInterval: Self.dailyInterval, retryInterval: Self.retryInterval, force: buildChanged, - operation: { - sharing ? await self.send(reason: sendReason) : await self.sendAnonymous(reason: sendReason) - }, + operation: { await self.send(reason: sendReason) }, onAttempt: { await MainActor.run { Storage.shared.telemetryLastAttemptAt.value = now } }, - onSuccess: { - await MainActor.run { - if sharing { - self.recordSuccessfulSend() - } else { - self.recordSuccessfulAnonymousSend() - } - } - } + onSuccess: { await MainActor.run { self.recordSuccessfulSend() } } ) } @@ -319,13 +308,6 @@ final class TelemetryClient { storage.telemetryLastAttemptAt.value = nil } - private func recordSuccessfulAnonymousSend() { - let storage = Storage.shared - storage.telemetryAnonymousLastSentAt.value = Date() - storage.telemetryLastSentBuild.value = Self.buildIdentity - storage.telemetryLastAttemptAt.value = nil - } - // MARK: - Payload /// The exact payload that would be POSTed right now. Pure function: useful @@ -392,26 +374,6 @@ final class TelemetryClient { return payload } - /// Everything the anonymous heartbeat carries, for the "What's sent" - /// preview. The trigger varies per request. - func anonymousPreview() -> [String: Any] { - var preview: [String: Any] = [ - "appVersion": Self.appVersion, - "installId": installId(), - "reason": "what triggered the check-in, e.g. cold_launch", - ] - if !Self.commitSha.isEmpty { - preview["commitSha"] = Self.commitSha - } - if let buildDate = Self.buildDate { - preview["buildDate"] = buildDate - } - if let failure = Storage.shared.telemetryLastFailureReason.value { - preview["lastFailureReason"] = failure - } - return preview - } - // MARK: - Send /// URL for `path` with the trigger reason and the previous attempt's @@ -440,17 +402,6 @@ final class TelemetryClient { request.httpMethod = method request.timeoutInterval = 15 request.setValue("Bearer \(Self.writeToken)", forHTTPHeaderField: "Authorization") - request.setValue(Self.appVersion, forHTTPHeaderField: "X-LoopFollow-Version") - request.setValue(installId(), forHTTPHeaderField: "X-LoopFollow-InstallId") - if !Self.commitSha.isEmpty { - request.setValue(Self.commitSha, forHTTPHeaderField: "X-LoopFollow-CommitSha") - } - if let buildDate = Self.buildDate { - request.setValue(buildDate, forHTTPHeaderField: "X-LoopFollow-BuildDate") - } - #if targetEnvironment(simulator) - request.setValue("true", forHTTPHeaderField: "X-LoopFollow-Simulator") - #endif return request } @@ -497,11 +448,6 @@ final class TelemetryClient { return await perform(request, label: "checkin") } - private func sendAnonymous(reason: SendReason) async -> Bool { - let request = await MainActor.run { self.request(path: "anonymous", method: "GET", reason: reason) } - return await perform(request, label: "anonymous") - } - // MARK: - Helpers /// Salted SHA-256, truncated to 16 hex chars (64 bits). @@ -546,18 +492,16 @@ final class TelemetryClient { // MARK: - TelemetryPreviewView /// Renders the exact payload that would be sent right now, with a copy -/// button. Linked to from the Diagnostics section in Settings and from the -/// consent sheet's "See exactly what's sent" button. +/// button. Linked to from the Diagnostics section in Settings. struct TelemetryPreviewView: View { @State private var jsonText: String = "" - @State private var sharing = Storage.shared.telemetryEnabled.value var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { - Text(sharing + Text(Storage.shared.telemetryEnabled.value ? "Below is the exact JSON object that LoopFollow would send to lf.bjorkert.se right now." - : "Usage stats are off. LoopFollow sends only this minimal check-in to lf.bjorkert.se, once a day and after installing a new build.") + : "Usage stats are off, so nothing is sent. Below is the JSON object LoopFollow would send if they were on.") .font(.subheadline) .foregroundColor(.secondary) .padding(.bottom, 4) @@ -582,14 +526,12 @@ struct TelemetryPreviewView: View { .navigationTitle("What's sent") .navigationBarTitleDisplayMode(.inline) .onAppear { - sharing = Storage.shared.telemetryEnabled.value - jsonText = Self.renderPayload(sharing: sharing) + jsonText = Self.renderPayload() } } - private static func renderPayload(sharing: Bool) -> String { - let payload = sharing ? TelemetryClient.shared.buildPayload() : TelemetryClient.shared.anonymousPreview() - guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]), + private static func renderPayload() -> String { + guard let data = try? JSONSerialization.data(withJSONObject: TelemetryClient.shared.buildPayload(), options: [.prettyPrinted, .sortedKeys]), let text = String(data: data, encoding: .utf8) else { return "Unable to render payload." } return text @@ -631,7 +573,7 @@ struct TelemetryPrivacyView: View { Group { Text("Opt out") .font(.headline) - Text("Use the Send anonymous usage stats toggle above. Turning it off is immediate and persistent. After that, the app sends only a minimal check-in, once a day and after installing a new build, so the developers can count how many installs run each version: the app version, build commit and build date, the random install ID, and the trigger and error category described above. It contains no device, settings, or health information.") + Text("Use the Send anonymous usage stats toggle above. Turning it off is immediate and persistent, and nothing is sent after that.") } Group { diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index c790cf3ac..d7b934196 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -216,7 +216,6 @@ class Storage { var telemetryInstallId = StorageValue(key: "telemetryInstallId", defaultValue: "") var telemetryLastSentAt = StorageValue(key: "telemetryLastSentAt", defaultValue: nil) var telemetryLastSentBuild = StorageValue(key: "telemetryLastSentBuild", defaultValue: "") - var telemetryAnonymousLastSentAt = StorageValue(key: "telemetryAnonymousLastSentAt", defaultValue: nil) var telemetryLastAttemptAt = StorageValue(key: "telemetryLastAttemptAt", defaultValue: nil) var telemetryLastFailureReason = StorageValue(key: "telemetryLastFailureReason", defaultValue: nil) diff --git a/Tests/TelemetrySendGateTests.swift b/Tests/TelemetrySendGateTests.swift index 098a06b3c..c47768c5f 100644 --- a/Tests/TelemetrySendGateTests.swift +++ b/Tests/TelemetrySendGateTests.swift @@ -105,11 +105,11 @@ struct TelemetrySendGateTests { let base = try #require(URL(string: "https://example.org/api/telemetry/")) let url = try #require(TelemetryClient.requestURL( baseURL: base, - path: "anonymous", + path: "checkin", reason: .buildChange, lastFailureReason: "checkin_http_500" )) - #expect(url.absoluteString == "https://example.org/api/telemetry/anonymous?reason=build_change&lastFailureReason=checkin_http_500") + #expect(url.absoluteString == "https://example.org/api/telemetry/checkin?reason=build_change&lastFailureReason=checkin_http_500") let plain = try #require(TelemetryClient.requestURL(baseURL: base, path: "checkin", reason: .timer, lastFailureReason: nil)) #expect(plain.absoluteString == "https://example.org/api/telemetry/checkin?reason=timer")