codeburn/mac/Sources/CodeBurnMenubar/Data/Telemetry.swift
Resham Joshi b876a311e2
feat: telemetry for CLI error causes, provider read failures and update results (#1702)
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.
2026-10-09 12:47:49 +02:00

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 }
}