mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-25 08:09:35 +00:00
cli_error gains duration, exit, a locally classified reason and the scoped provider. New provider_read_fail (desktop) and update_result (desktop, menubar, tray) events, enums only.
782 lines
31 KiB
Swift
782 lines
31 KiB
Swift
import Foundation
|
|
|
|
/// Anonymous, consent-gated product telemetry for the menu bar app.
|
|
///
|
|
/// The macOS twin of `windows/src-tauri/src/telemetry.rs`: same endpoint, same
|
|
/// `app.name`, same event names, same sanitizer, same day granularity, so the
|
|
/// two trays land in one table and separate on `app.platform`.
|
|
///
|
|
/// Privacy invariants (enforced here, not by the caller):
|
|
///
|
|
/// - Nothing is sent while the toggle is off. When the desktop app's own state
|
|
/// file exists, its decision and its install id are used and this app never
|
|
/// writes that file: one decision covers both, and the two apps' events join
|
|
/// on one id. Standalone, the decision lives in this app's own defaults and
|
|
/// defaults off for EU / EEA / UK / CH and for an unknown region.
|
|
/// - The only identifier is a random id minted locally. Switching the toggle
|
|
/// off mints a fresh one so past and future data cannot be linked.
|
|
/// - Events carry a day-granularity date only, and every prop goes through the
|
|
/// whitelist sanitizer below. No paths, no session content, no exact amounts.
|
|
/// - Debug builds and `swift run` never send (`CODEBURN_TELEMETRY_DEV=1`
|
|
/// overrides, for end-to-end testing).
|
|
|
|
// MARK: - JSON
|
|
|
|
/// Enough of a JSON value to carry the CLI's `telemetrySnapshot` block from the
|
|
/// payload decoder to the wire without this app knowing — or re-deriving —
|
|
/// anything about what is in it.
|
|
enum JSONValue: Codable, Sendable, Equatable {
|
|
case null
|
|
case bool(Bool)
|
|
case int(Int)
|
|
case double(Double)
|
|
case string(String)
|
|
case array([JSONValue])
|
|
case object([String: JSONValue])
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
if container.decodeNil() { self = .null; return }
|
|
if let value = try? container.decode(Bool.self) { self = .bool(value); return }
|
|
if let value = try? container.decode(Int.self) { self = .int(value); return }
|
|
if let value = try? container.decode(Double.self) { self = .double(value); return }
|
|
if let value = try? container.decode(String.self) { self = .string(value); return }
|
|
if let value = try? container.decode([JSONValue].self) { self = .array(value); return }
|
|
self = .object(try container.decode([String: JSONValue].self))
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.singleValueContainer()
|
|
switch self {
|
|
case .null: try container.encodeNil()
|
|
case .bool(let value): try container.encode(value)
|
|
case .int(let value): try container.encode(value)
|
|
case .double(let value): try container.encode(value)
|
|
case .string(let value): try container.encode(value)
|
|
case .array(let value): try container.encode(value)
|
|
case .object(let value): try container.encode(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Consent
|
|
|
|
enum TelemetryConsentSource: String, Sendable {
|
|
/// The desktop app decided, and this app is only reading its answer.
|
|
case desktop
|
|
/// A standalone menu bar install, which decides for itself.
|
|
case app
|
|
}
|
|
|
|
struct TelemetryConsent: Equatable, Sendable {
|
|
let source: TelemetryConsentSource
|
|
let installID: String
|
|
/// The effective answer: the decision that owns this install, less this
|
|
/// app's own veto.
|
|
let enabled: Bool
|
|
/// Desktop: that app's consent screen has been answered. App: a decision
|
|
/// has been recorded here, by the toggle or inherited from a desktop file
|
|
/// this app has seen.
|
|
let decided: Bool
|
|
/// The desktop app's own answer is no. The local toggle can veto that app's
|
|
/// yes; it can never overturn its no, so the toggle reads as a readout.
|
|
let lockedOff: Bool
|
|
|
|
/// `requiresExplicitDecision` is the one expression standing between
|
|
/// today's behaviour and "a standalone install must be asked first": see
|
|
/// `Telemetry.standaloneRequiresExplicitDecision`.
|
|
func canTrack(requiresExplicitDecision: Bool) -> Bool {
|
|
guard enabled else { return false }
|
|
switch source {
|
|
case .desktop: return decided
|
|
case .app: return decided || !requiresExplicitDecision
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The desktop app's `telemetry.v1.json`, of which only these three fields
|
|
/// matter here. Read-only, always: that app owns the file and rewrites it whole.
|
|
struct DesktopTelemetryState: Equatable, Sendable {
|
|
let installID: String
|
|
let enabled: Bool
|
|
let onboarded: Bool
|
|
}
|
|
|
|
// MARK: - The queue
|
|
|
|
struct TelemetryEvent: Codable, Equatable, Sendable {
|
|
let name: String
|
|
let day: String
|
|
let props: [String: JSONValue]
|
|
}
|
|
|
|
private struct TelemetryEnvelope: Encodable {
|
|
struct App: Encodable {
|
|
let name: String
|
|
let version: String
|
|
let platform: String
|
|
let arch: String
|
|
let country: String?
|
|
}
|
|
|
|
let schema: Int
|
|
let installId: String
|
|
let app: App
|
|
let events: [TelemetryEvent]
|
|
}
|
|
|
|
enum TelemetryPostOutcome: Sendable {
|
|
case sent
|
|
/// A 4xx: this batch will never be accepted.
|
|
case rejected
|
|
/// A 5xx, a timeout or no network: worth another beat.
|
|
case retry
|
|
}
|
|
|
|
protocol TelemetryTransport: Sendable {
|
|
func post(
|
|
_ body: Data,
|
|
to endpoint: URL,
|
|
timeout: TimeInterval,
|
|
completion: @escaping @Sendable (TelemetryPostOutcome) -> Void
|
|
)
|
|
}
|
|
|
|
/// A session of this app's own, never `URLSession.shared`.
|
|
///
|
|
/// The shared session carries a process-wide cookie jar and response cache, so
|
|
/// a `Set-Cookie` from the endpoint or from a CDN in front of it would outlive
|
|
/// both an opt-out and the install id that rotates with it — a stable
|
|
/// identifier arriving by the back door. The default User-Agent is a second
|
|
/// one: it spells out the CFNetwork and Darwin kernel build. Ephemeral storage,
|
|
/// no cookies, no cache, a fixed agent, and no redirect followed at all —
|
|
/// `telemetry.rs` pins https across redirects, and refusing them outright is
|
|
/// the same guarantee with less to get wrong.
|
|
final class URLSessionTelemetryTransport: NSObject, TelemetryTransport, URLSessionTaskDelegate, @unchecked Sendable {
|
|
static func makeConfiguration() -> URLSessionConfiguration {
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.httpCookieStorage = nil
|
|
configuration.httpShouldSetCookies = false
|
|
configuration.httpCookieAcceptPolicy = .never
|
|
configuration.urlCache = nil
|
|
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
|
configuration.tlsMinimumSupportedProtocolVersion = .TLSv12
|
|
configuration.httpAdditionalHeaders = [
|
|
"Content-Type": "application/json",
|
|
"User-Agent": Telemetry.appName,
|
|
]
|
|
return configuration
|
|
}
|
|
|
|
private let session = URLSession(configuration: makeConfiguration())
|
|
|
|
func post(
|
|
_ body: Data,
|
|
to endpoint: URL,
|
|
timeout: TimeInterval,
|
|
completion: @escaping @Sendable (TelemetryPostOutcome) -> Void
|
|
) {
|
|
var request = URLRequest(url: endpoint, timeoutInterval: timeout)
|
|
request.httpMethod = "POST"
|
|
request.httpBody = body
|
|
let task = session.dataTask(with: request) { _, response, _ in
|
|
guard let status = (response as? HTTPURLResponse)?.statusCode else {
|
|
completion(.retry)
|
|
return
|
|
}
|
|
if (200..<300).contains(status) {
|
|
completion(.sent)
|
|
} else if (400..<500).contains(status) {
|
|
completion(.rejected)
|
|
} else {
|
|
completion(.retry)
|
|
}
|
|
}
|
|
task.delegate = self
|
|
task.resume()
|
|
}
|
|
|
|
/// Refused, not followed: a redirect names a different endpoint than the one
|
|
/// this app decided to trust, and downgrading to plain http is one of the
|
|
/// things it could name.
|
|
func urlSession(
|
|
_ session: URLSession,
|
|
task: URLSessionTask,
|
|
willPerformHTTPRedirection response: HTTPURLResponse,
|
|
newRequest request: URLRequest,
|
|
completionHandler: @escaping (URLRequest?) -> Void
|
|
) {
|
|
completionHandler(nil)
|
|
}
|
|
}
|
|
|
|
/// What the Privacy section renders. `source` decides whether the toggle is
|
|
/// live or a readout of the desktop app's decision.
|
|
struct TelemetryStatus: Equatable, Sendable {
|
|
let enabled: Bool
|
|
let source: TelemetryConsentSource
|
|
/// The desktop app says no. The toggle shows that answer and cannot change
|
|
/// it; every other combination leaves the toggle usable as an off switch.
|
|
let isLocked: Bool
|
|
}
|
|
|
|
// MARK: - The client
|
|
|
|
@MainActor
|
|
final class Telemetry {
|
|
/// The app's instance. A build that can never send has no business reading
|
|
/// the desktop app's state file either, which is also what keeps `swift run`
|
|
/// and `swift test` away from it — the suites inject their own everything.
|
|
static let shared = Telemetry(
|
|
desktopStateURL: defaultMaySend() ? defaultDesktopStateURL : nil
|
|
)
|
|
|
|
static let endpoint = URL(string: "https://api.codeburn.app/v1/telemetry")!
|
|
static let schema = 1
|
|
/// What separates these rows from the desktop app's (`codeburn-desktop`).
|
|
/// Also the transport's User-Agent, which is built off the main actor.
|
|
nonisolated static let appName = "codeburn-menubar"
|
|
static let releaseBundleID = "org.agentseal.codeburn-menubar"
|
|
|
|
/// EU-27 + EEA (IS, LI, NO) + UK + CH: the conservative "default off"
|
|
/// region. Copied from `app/electron/telemetry.ts`; the suite asserts the
|
|
/// two lists are still the same set.
|
|
static let defaultOffCountries: Set<String> = [
|
|
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR",
|
|
"HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK",
|
|
"SI", "ES", "SE", "IS", "LI", "NO", "GB", "CH",
|
|
]
|
|
|
|
/// Every event this app may send. An unknown name is dropped rather than
|
|
/// forwarded, so a typo at a call site cannot invent a metric. `update_click`,
|
|
/// `glance_open`, `dock_provider_switch` and `dock_drag_end` have no call
|
|
/// site here yet: they are the Windows tray's vocabulary for the same
|
|
/// surfaces, kept so the two apps stay one list.
|
|
static let eventNames: Set<String> = [
|
|
"app_open", "app_close", "popover_open", "settings_open", "update_click",
|
|
"glance_open", "dock_enabled", "dock_disabled", "dock_provider_switch",
|
|
"dock_drag_end", "usage_snapshot", "update_result",
|
|
]
|
|
|
|
static let maxQueue = 200
|
|
static let maxString = 64
|
|
static let maxArray = 12
|
|
static let maxKeys = 16
|
|
/// How deep a container may sit below the props object. The daily usage
|
|
/// snapshot is the deepest shape either app sends: props -> models[] ->
|
|
/// model -> tasks[] -> task. Anything deeper is dropped whole.
|
|
static let maxDepth = 5
|
|
/// Belt and braces on top of the per-level caps.
|
|
static let maxLeaves = 1_000
|
|
|
|
static let httpTimeout: TimeInterval = 10
|
|
/// Quit waits for the last batch, so it gets a timeout nobody would notice.
|
|
static let quitTimeout: TimeInterval = 1.5
|
|
|
|
static let enabledKey = "CodeBurnTelemetryEnabled"
|
|
/// Set once any decision has been recorded here — the toggle's, or the
|
|
/// desktop app's, inherited the first time a valid state file is seen.
|
|
static let decidedKey = "CodeBurnTelemetryDecided"
|
|
/// This app's veto over the desktop app's yes. Kept apart from
|
|
/// `enabledKey` so it survives the desktop answer being re-inherited.
|
|
static let localOptOutKey = "CodeBurnTelemetryLocalOptOut"
|
|
static let installIDKey = "CodeBurnTelemetryInstallId"
|
|
static let lastSnapshotDayKey = "CodeBurnTelemetryLastSnapshotDay"
|
|
|
|
/// Whether a standalone install has to be asked before anything is sent, or
|
|
/// whether the region default is itself the answer. Today it is the region
|
|
/// default, as on the Windows tray before its notice appears; flipping this
|
|
/// to `true` is the whole of "standalone needs a first-run notice", and
|
|
/// nothing else reads the `decidedKey` marker.
|
|
static let standaloneRequiresExplicitDecision = false
|
|
|
|
private let defaults: UserDefaults
|
|
private let desktopStateURL: URL?
|
|
private let country: String?
|
|
private let appVersion: String
|
|
private let endpoint: URL
|
|
private let transport: TelemetryTransport
|
|
/// False in a debug build or a `swift run`: the queue still fills, so the
|
|
/// wiring can be exercised, but nothing leaves the machine.
|
|
private let maySend: Bool
|
|
private let now: @Sendable () -> Date
|
|
private let requiresExplicitDecision: Bool
|
|
|
|
private var consent: TelemetryConsent
|
|
|
|
/// The consent this run actually sends on.
|
|
private var canTrack: Bool {
|
|
consent.canTrack(requiresExplicitDecision: requiresExplicitDecision)
|
|
}
|
|
/// Memory only, unlike the Windows tray's file-backed queue: a run that
|
|
/// dies, or whose quit flush does not land inside its timeout, loses that
|
|
/// session's events and that day's `usage_snapshot` with them.
|
|
private var queue: [TelemetryEvent] = []
|
|
private var openedAt: Date
|
|
private var flushing = false
|
|
/// Set when the toggle goes off while a batch is out. That batch is dropped
|
|
/// when it comes back rather than restored, mirroring `Queue::clear`'s
|
|
/// `abandon_in_flight` in the Windows tray.
|
|
private var abandonInFlight = false
|
|
/// Consecutive failed sends, which is what the beat's backoff is computed
|
|
/// from, and how many beats are still owed to it.
|
|
private var failures = 0
|
|
private var beatsOwed = 0
|
|
private var flushBeat: NSBackgroundActivityScheduler?
|
|
|
|
init(
|
|
defaults: UserDefaults = .standard,
|
|
desktopStateURL: URL? = Telemetry.defaultDesktopStateURL,
|
|
region: String? = Locale.current.region?.identifier,
|
|
appVersion: String = AppVersion.normalizedBundleShortVersion,
|
|
endpoint: URL = Telemetry.endpoint,
|
|
transport: TelemetryTransport = URLSessionTelemetryTransport(),
|
|
maySend: Bool = Telemetry.defaultMaySend(),
|
|
now: @escaping @Sendable () -> Date = Date.init,
|
|
requiresExplicitDecision: Bool = Telemetry.standaloneRequiresExplicitDecision
|
|
) {
|
|
self.defaults = defaults
|
|
self.desktopStateURL = desktopStateURL
|
|
self.country = Telemetry.normalizedCountry(region)
|
|
self.appVersion = appVersion
|
|
self.endpoint = endpoint
|
|
self.transport = transport
|
|
self.maySend = maySend
|
|
self.now = now
|
|
self.requiresExplicitDecision = requiresExplicitDecision
|
|
self.openedAt = now()
|
|
self.consent = Telemetry.resolveConsent(
|
|
desktop: Telemetry.readDesktopState(at: desktopStateURL),
|
|
defaults: defaults,
|
|
country: Telemetry.normalizedCountry(region)
|
|
)
|
|
reresolve()
|
|
}
|
|
|
|
// MARK: Environment
|
|
|
|
/// `~/Library/Application Support/codeburn-desktop/telemetry.v1.json`, which
|
|
/// is Electron's `userData` for that app (its package name) plus the file
|
|
/// name its Telemetry class writes.
|
|
static var defaultDesktopStateURL: URL? {
|
|
FileManager.default
|
|
.urls(for: .applicationSupportDirectory, in: .userDomainMask)
|
|
.first?
|
|
.appendingPathComponent("codeburn-desktop")
|
|
.appendingPathComponent("telemetry.v1.json")
|
|
}
|
|
|
|
/// Mirrors the desktop app's "packaged only" rule with this app's
|
|
/// equivalent: a release build running out of the signed bundle.
|
|
static func defaultMaySend(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
|
) -> Bool {
|
|
if environment["CODEBURN_TELEMETRY_DEV"] == "1" { return true }
|
|
#if DEBUG
|
|
return false
|
|
#else
|
|
return Bundle.main.bundleIdentifier == releaseBundleID
|
|
#endif
|
|
}
|
|
|
|
/// Only a two-letter alpha region is a country here, so the UN M.49 forms
|
|
/// (`419`) come back as unknown rather than as a country nobody can join on.
|
|
static func normalizedCountry(_ region: String?) -> String? {
|
|
guard let region, region.count == 2,
|
|
region.allSatisfy({ $0.isASCII && $0.isLetter }) else { return nil }
|
|
return region.uppercased()
|
|
}
|
|
|
|
/// An unknown region is the conservative case and defaults off, exactly as
|
|
/// the desktop does.
|
|
static func defaultEnabled(for country: String?) -> Bool {
|
|
guard let country else { return false }
|
|
return !defaultOffCountries.contains(country.uppercased())
|
|
}
|
|
|
|
// MARK: Consent
|
|
|
|
/// A version this build does not understand, a missing id or a missing
|
|
/// toggle all mean "the desktop app has not decided", which sends the
|
|
/// resolution on to this app's own defaults rather than guessing on the
|
|
/// desktop app's behalf.
|
|
static func parseDesktopState(_ data: Data) -> DesktopTelemetryState? {
|
|
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
root["version"] as? Int == 1,
|
|
let installID = root["installId"] as? String, !installID.isEmpty,
|
|
let enabled = root["enabled"] as? Bool else { return nil }
|
|
let onboardedAt = root["onboardedAt"] as? String
|
|
return DesktopTelemetryState(
|
|
installID: installID,
|
|
enabled: enabled,
|
|
onboarded: !(onboardedAt ?? "").isEmpty
|
|
)
|
|
}
|
|
|
|
static func readDesktopState(at url: URL?) -> DesktopTelemetryState? {
|
|
guard let url, let data = try? Data(contentsOf: url) else { return nil }
|
|
return parseDesktopState(data)
|
|
}
|
|
|
|
/// The one decision every send is gated on. A valid desktop file wins
|
|
/// outright, so a menu bar app running beside the desktop app never asks its
|
|
/// own question.
|
|
static func resolveConsent(
|
|
desktop: DesktopTelemetryState?,
|
|
defaults: UserDefaults,
|
|
country: String?
|
|
) -> TelemetryConsent {
|
|
let vetoed = defaults.bool(forKey: localOptOutKey)
|
|
if let desktop {
|
|
return TelemetryConsent(
|
|
source: .desktop,
|
|
installID: desktop.installID,
|
|
enabled: desktop.enabled && !vetoed,
|
|
decided: desktop.onboarded,
|
|
lockedOff: !desktop.enabled
|
|
)
|
|
}
|
|
let stored = defaults.string(forKey: installIDKey)
|
|
// A decision recorded here wins over the region default, which is what
|
|
// keeps a desktop app's "no" standing after that app is deleted: its
|
|
// answer was inherited locally the last time its file was read.
|
|
let recorded = defaults.object(forKey: enabledKey) as? Bool
|
|
return TelemetryConsent(
|
|
source: .app,
|
|
installID: (stored?.isEmpty == false ? stored : nil) ?? UUID().uuidString,
|
|
enabled: (recorded ?? defaultEnabled(for: country)) && !vetoed,
|
|
decided: defaults.bool(forKey: decidedKey),
|
|
lockedOff: false
|
|
)
|
|
}
|
|
|
|
/// Re-reads the desktop app's file, because it can appear or change while
|
|
/// this app runs: the desktop app can be installed after the menu bar app,
|
|
/// and its consent screen is answered in a different process.
|
|
@discardableResult
|
|
private func reresolve() -> TelemetryConsent {
|
|
let desktop = Telemetry.readDesktopState(at: desktopStateURL)
|
|
var fresh = Telemetry.resolveConsent(
|
|
desktop: desktop,
|
|
defaults: defaults,
|
|
country: country
|
|
)
|
|
if let desktop {
|
|
inheritDesktopDecision(desktop)
|
|
} else if consent.source == .app,
|
|
defaults.string(forKey: Telemetry.installIDKey) == nil {
|
|
// A defaults write that did not land leaves this app's id unstored,
|
|
// and resolving would mint a fresh one every time it is asked. Keep
|
|
// the one this run already has.
|
|
fresh = TelemetryConsent(
|
|
source: .app,
|
|
installID: consent.installID,
|
|
enabled: fresh.enabled,
|
|
decided: fresh.decided,
|
|
lockedOff: false
|
|
)
|
|
}
|
|
// One install, one id: stored the moment it is minted, not only at
|
|
// init, so a run that starts beside the desktop app and outlives it
|
|
// does not mint a new id at every resolve.
|
|
if fresh.source == .app,
|
|
defaults.string(forKey: Telemetry.installIDKey) != fresh.installID {
|
|
defaults.set(fresh.installID, forKey: Telemetry.installIDKey)
|
|
}
|
|
consent = fresh
|
|
return fresh
|
|
}
|
|
|
|
/// Records the desktop app's answer here as well. Deleting that app leaves
|
|
/// its `telemetry.v1.json` behind on a Mac, but a file that is removed or
|
|
/// corrupted would otherwise hand the question back to the region default
|
|
/// — turning an explicit "no" into a "yes" on the next resolve.
|
|
private func inheritDesktopDecision(_ desktop: DesktopTelemetryState) {
|
|
if defaults.object(forKey: Telemetry.enabledKey) as? Bool != desktop.enabled {
|
|
defaults.set(desktop.enabled, forKey: Telemetry.enabledKey)
|
|
}
|
|
if desktop.onboarded, !defaults.bool(forKey: Telemetry.decidedKey) {
|
|
defaults.set(true, forKey: Telemetry.decidedKey)
|
|
}
|
|
}
|
|
|
|
func status() -> TelemetryStatus {
|
|
let consent = reresolve()
|
|
return TelemetryStatus(
|
|
enabled: consent.enabled,
|
|
source: consent.source,
|
|
isLocked: consent.lockedOff
|
|
)
|
|
}
|
|
|
|
/// The settings toggle.
|
|
///
|
|
/// Standalone it is the decision: off empties the queue, disowns a batch
|
|
/// that is still in flight and mints a fresh install id, so past and future
|
|
/// data cannot be linked. Under the desktop app's decision it is a veto and
|
|
/// nothing more — it can switch this app off while that app says yes, never
|
|
/// on while it says no, and it neither rotates an id that app owns nor
|
|
/// writes that app's file.
|
|
func setEnabled(_ enabled: Bool) {
|
|
let consent = reresolve()
|
|
switch consent.source {
|
|
case .desktop:
|
|
guard !consent.lockedOff else { return }
|
|
defaults.set(!enabled, forKey: Telemetry.localOptOutKey)
|
|
if !enabled { stopSending(rotatingInstallID: false) }
|
|
case .app:
|
|
defaults.set(enabled, forKey: Telemetry.enabledKey)
|
|
defaults.set(true, forKey: Telemetry.decidedKey)
|
|
defaults.set(false, forKey: Telemetry.localOptOutKey)
|
|
if !enabled { stopSending(rotatingInstallID: true) }
|
|
}
|
|
reresolve()
|
|
}
|
|
|
|
/// Everything recorded under the answer being withdrawn goes, including a
|
|
/// batch that is already out: it would otherwise come back on a retry and
|
|
/// be posted under the id that replaced it.
|
|
private func stopSending(rotatingInstallID: Bool) {
|
|
queue.removeAll()
|
|
abandonInFlight = flushing
|
|
if rotatingInstallID {
|
|
defaults.set(UUID().uuidString, forKey: Telemetry.installIDKey)
|
|
}
|
|
}
|
|
|
|
// MARK: Sanitizing
|
|
|
|
/// Whitelist sanitizer. Keeps short strings, finite numbers and booleans,
|
|
/// plus objects and arrays of them nested up to `maxDepth`, each level
|
|
/// capped by `maxKeys` / `maxArray` and the whole event by `maxLeaves`.
|
|
/// Everything else is dropped. Port of `sanitizeProps` in the desktop app.
|
|
static func sanitizeProps(_ props: JSONValue) -> [String: JSONValue] {
|
|
guard case .object(let fields) = props else { return [:] }
|
|
var budget = maxLeaves
|
|
return sanitizeObject(fields, depth: 1, budget: &budget)
|
|
}
|
|
|
|
private static func sanitizeObject(
|
|
_ fields: [String: JSONValue],
|
|
depth: Int,
|
|
budget: inout Int
|
|
) -> [String: JSONValue] {
|
|
var out: [String: JSONValue] = [:]
|
|
// Sorted so the key cap keeps the same sixteen keys the Windows tray
|
|
// does, whose serde map is likewise ordered by key.
|
|
for key in fields.keys.sorted() {
|
|
if out.count >= maxKeys { break }
|
|
guard let clean = sanitizeValue(fields[key]!, depth: depth, budget: &budget) else {
|
|
continue
|
|
}
|
|
out[truncate(key)] = clean
|
|
}
|
|
return out
|
|
}
|
|
|
|
private static func sanitizeValue(
|
|
_ value: JSONValue,
|
|
depth: Int,
|
|
budget: inout Int
|
|
) -> JSONValue? {
|
|
switch value {
|
|
case .null:
|
|
return nil
|
|
case .bool, .int:
|
|
guard budget > 0 else { return nil }
|
|
budget -= 1
|
|
return value
|
|
case .double(let number):
|
|
guard budget > 0, number.isFinite else { return nil }
|
|
budget -= 1
|
|
return value
|
|
case .string(let text):
|
|
guard budget > 0 else { return nil }
|
|
budget -= 1
|
|
return .string(truncate(text))
|
|
case .array(let entries):
|
|
// Only leaves are allowed at the bottom; a container here is dropped
|
|
// whole rather than truncated to something that reads complete.
|
|
guard depth < maxDepth else { return nil }
|
|
var items: [JSONValue] = []
|
|
for entry in entries.prefix(maxArray) {
|
|
if let clean = sanitizeValue(entry, depth: depth + 1, budget: &budget) {
|
|
items.append(clean)
|
|
}
|
|
}
|
|
return items.isEmpty ? nil : .array(items)
|
|
case .object(let fields):
|
|
guard depth < maxDepth else { return nil }
|
|
let flat = sanitizeObject(fields, depth: depth + 1, budget: &budget)
|
|
return flat.isEmpty ? nil : .object(flat)
|
|
}
|
|
}
|
|
|
|
private static func truncate(_ text: String) -> String {
|
|
String(text.prefix(maxString))
|
|
}
|
|
|
|
// MARK: Tracking
|
|
|
|
/// `YYYY-MM-DD` in the machine's own timezone, the desktop app's day key.
|
|
static func dayKey(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
/// Queues one event. An unknown name, junk props or a withheld decision are
|
|
/// all dropped here rather than reaching the wire.
|
|
func track(_ name: String, _ props: JSONValue = .object([:])) {
|
|
guard Telemetry.eventNames.contains(name), canTrack else { return }
|
|
// The oldest event gives way at the cap, so the queue always carries the
|
|
// most recent window rather than freezing at whatever filled it first.
|
|
if queue.count >= Telemetry.maxQueue { queue.removeFirst() }
|
|
queue.append(TelemetryEvent(
|
|
name: name,
|
|
day: Telemetry.dayKey(now()),
|
|
props: Telemetry.sanitizeProps(props)
|
|
))
|
|
}
|
|
|
|
/// The CLI's daily aggregate, forwarded verbatim and at most once a day. The
|
|
/// desktop app sends the same block from its own process when it is the one
|
|
/// that decided, and two apps sending it under one install id would double
|
|
/// every figure in it.
|
|
func trackUsageSnapshot(_ snapshot: JSONValue?) {
|
|
guard let snapshot, case .object = snapshot else { return }
|
|
guard canTrack, consent.source == .app else { return }
|
|
let day = Telemetry.dayKey(now())
|
|
guard defaults.string(forKey: Telemetry.lastSnapshotDayKey) != day else { return }
|
|
defaults.set(day, forKey: Telemetry.lastSnapshotDayKey)
|
|
track("usage_snapshot", snapshot)
|
|
}
|
|
|
|
/// Session length, in whole minutes, for the last flush before the process
|
|
/// goes.
|
|
func trackClose() {
|
|
let minutes = Int((now().timeIntervalSince(openedAt) / 60).rounded())
|
|
track("app_close", .object(["sessionMinutes": .int(minutes)]))
|
|
}
|
|
|
|
// MARK: Flushing
|
|
|
|
/// The flush beat, hung off the same background-activity scheduler the
|
|
/// refresh backstop uses: coalescible, tolerant, and no new fast timer in an
|
|
/// app that is measured on idle energy.
|
|
func start() {
|
|
track("app_open")
|
|
guard flushBeat == nil else { return }
|
|
let scheduler = NSBackgroundActivityScheduler(
|
|
identifier: "\(Telemetry.releaseBundleID).telemetry")
|
|
scheduler.repeats = true
|
|
scheduler.interval = 300
|
|
scheduler.tolerance = 60
|
|
scheduler.qualityOfService = .background
|
|
scheduler.schedule { [weak self] completion in
|
|
Task { @MainActor in
|
|
self?.runFlushBeat()
|
|
completion(.finished)
|
|
}
|
|
}
|
|
flushBeat = scheduler
|
|
}
|
|
|
|
/// Beats to sit out after `failures` consecutive failed sends: the Windows
|
|
/// tray's doubling backoff, in five-minute beats and capped at half an hour,
|
|
/// so an endpoint that has been down all day is still asked twice an hour.
|
|
static func beatsToSkip(failures: Int) -> Int {
|
|
guard failures > 0 else { return 0 }
|
|
return min(1 << min(failures - 1, 8), 6) - 1
|
|
}
|
|
|
|
func runFlushBeat() {
|
|
if beatsOwed > 0 {
|
|
beatsOwed -= 1
|
|
return
|
|
}
|
|
flush()
|
|
}
|
|
|
|
/// Best-effort batch POST. A 4xx drops the batch, because retrying a payload
|
|
/// the server has already refused would wedge the queue at its cap; a 5xx or
|
|
/// a dead network keeps it for the next beat.
|
|
func flush() {
|
|
// Also where a decision made elsewhere is picked up: the desktop app can
|
|
// be installed, or answer its consent screen, while this app is running.
|
|
reresolve()
|
|
guard maySend, !flushing, let (body, batch) = makeBatch() else { return }
|
|
flushing = true
|
|
abandonInFlight = false
|
|
transport.post(body, to: endpoint, timeout: Telemetry.httpTimeout) { [weak self] outcome in
|
|
Task { @MainActor in
|
|
self?.settle(outcome, batch: batch)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The last flush, on the way out. Bounded so quitting never waits on a slow
|
|
/// network, and never restored: the process is going either way.
|
|
func flushOnQuit(timeout: TimeInterval = Telemetry.quitTimeout) {
|
|
trackClose()
|
|
reresolve()
|
|
guard maySend, let (body, _) = makeBatch() else { return }
|
|
let done = DispatchSemaphore(value: 0)
|
|
transport.post(body, to: endpoint, timeout: timeout) { _ in done.signal() }
|
|
_ = done.wait(timeout: .now() + timeout)
|
|
}
|
|
|
|
private func makeBatch() -> (Data, [TelemetryEvent])? {
|
|
guard canTrack, !queue.isEmpty else { return nil }
|
|
let batch = queue
|
|
let envelope = TelemetryEnvelope(
|
|
schema: Telemetry.schema,
|
|
installId: consent.installID,
|
|
app: TelemetryEnvelope.App(
|
|
name: Telemetry.appName,
|
|
version: appVersion,
|
|
platform: "darwin",
|
|
arch: Telemetry.arch,
|
|
country: country
|
|
),
|
|
events: batch
|
|
)
|
|
guard let body = try? JSONEncoder().encode(envelope) else { return nil }
|
|
queue.removeAll()
|
|
return (body, batch)
|
|
}
|
|
|
|
private func settle(_ outcome: TelemetryPostOutcome, batch: [TelemetryEvent]) {
|
|
flushing = false
|
|
if abandonInFlight {
|
|
abandonInFlight = false
|
|
failures = 0
|
|
beatsOwed = 0
|
|
return
|
|
}
|
|
switch outcome {
|
|
case .sent, .rejected:
|
|
// The endpoint answered, so it is up: only a refused payload is
|
|
// gone, and the next batch is a different payload.
|
|
failures = 0
|
|
beatsOwed = 0
|
|
case .retry:
|
|
// Back in front of whatever was queued while the batch was out.
|
|
queue = Array((batch + queue).suffix(Telemetry.maxQueue))
|
|
failures += 1
|
|
beatsOwed = Telemetry.beatsToSkip(failures: failures)
|
|
}
|
|
}
|
|
|
|
private static var arch: String {
|
|
#if arch(arm64)
|
|
"arm64"
|
|
#else
|
|
"x64"
|
|
#endif
|
|
}
|
|
|
|
/// Visible for tests.
|
|
var queuedEvents: [TelemetryEvent] { queue }
|
|
}
|