feat(app): add resilient Flow recovery and privacy-safe analytics
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

Unify PiP recovery across startup and foreground transitions, surface actionable status, and add privacy-safe analytics plus the refreshed onboarding and account experience. Update documentation assets and advance the release build to 84.
This commit is contained in:
Rocky
2026-08-20 20:22:20 +08:00
parent ee7de5c934
commit 10bc457c72
69 changed files with 7891 additions and 598 deletions
@@ -0,0 +1,404 @@
// AnalyticsClient.swift
// OSGKeyboard · Shared
//
// Synchronous, type-safe fire-and-forget API. Every asynchronous task owns only
// Sendable dependencies and analytics failures never escape into feature code.
import Foundation
public protocol AnalyticsClient: Sendable {
func recordSessionActivity()
func recordKeyboardActivated()
func recordPurchaseViewed()
func recordPurchaseStarted()
func recordPurchaseCancelled()
func recordReferralShared()
func recordInviteOpened(
acquisitionChannel: AnalyticsAcquisitionChannel,
surface: AnalyticsSurface
)
func startAIFeature(
_ feature: AnalyticsFeature,
executionMode: AnalyticsExecutionMode
) -> any AnalyticsAIOperation
}
public extension AnalyticsClient {
func recordInviteOpened(
acquisitionChannel: AnalyticsAcquisitionChannel = .referral
) {
recordInviteOpened(
acquisitionChannel: acquisitionChannel,
surface: .inviteWeb
)
}
}
public protocol AnalyticsAIOperation: Sendable {
func succeed()
func fail(category: AnalyticsFailureCategory)
func cancel()
}
public final class LiveAnalyticsClient: AnalyticsClient, Sendable {
private let repository: AnalyticsRepository
private let context: AnalyticsBootstrapContext
private let monotonicClock: any AnalyticsMonotonicClock
private let trigger: any AnalyticsUploadTriggering
public init(
repository: AnalyticsRepository,
context: AnalyticsBootstrapContext,
monotonicClock: any AnalyticsMonotonicClock = SystemAnalyticsMonotonicClock(),
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger()
) {
self.repository = repository
self.context = context
self.monotonicClock = monotonicClock
self.trigger = trigger
}
public func recordSessionActivity() {
Task {
await repository.recordSessionIfNeeded(context: context)
await trigger.requestUpload()
}
}
public func recordKeyboardActivated() {
enqueue(eventType: .keyboardActivated)
}
public func recordPurchaseViewed() {
enqueue(eventType: .purchaseViewed)
}
public func recordPurchaseStarted() {
enqueue(eventType: .purchaseStarted)
}
public func recordPurchaseCancelled() {
enqueue(
eventType: .purchaseCancelled,
dimensions: AnalyticsEventDimensions(failureCategory: .cancelled)
)
}
public func recordReferralShared() {
enqueue(eventType: .referralShared)
}
public func recordInviteOpened(
acquisitionChannel: AnalyticsAcquisitionChannel,
surface: AnalyticsSurface
) {
enqueue(
eventType: .inviteOpened,
surfaceOverride: surface,
dimensions: AnalyticsEventDimensions(
acquisitionChannel: acquisitionChannel
)
)
}
public func startAIFeature(
_ feature: AnalyticsFeature,
executionMode: AnalyticsExecutionMode
) -> any AnalyticsAIOperation {
let startNanoseconds = monotonicClock.nowNanoseconds()
let dimensions = AnalyticsEventDimensions(
feature: feature,
executionMode: executionMode
)
let startedTask = enqueue(
eventType: .aiFeatureStarted,
dimensions: dimensions
)
return LiveAnalyticsAIOperation(
repository: repository,
context: context,
feature: feature,
executionMode: executionMode,
startNanoseconds: startNanoseconds,
startedTask: startedTask,
monotonicClock: monotonicClock,
trigger: trigger
)
}
@discardableResult
private func enqueue(
eventType: AnalyticsEventType,
surfaceOverride: AnalyticsSurface? = nil,
dimensions: AnalyticsEventDimensions = .none
) -> Task<Void, Never> {
let recordTask = Task {
await repository.record(
eventType: eventType,
context: context,
surfaceOverride: surfaceOverride,
dimensions: dimensions
)
}
Task {
await recordTask.value
await trigger.requestUpload()
}
return recordTask
}
}
public final class LiveAnalyticsAIOperation: AnalyticsAIOperation, @unchecked Sendable {
private let repository: AnalyticsRepository
private let context: AnalyticsBootstrapContext
private let feature: AnalyticsFeature
private let executionMode: AnalyticsExecutionMode
private let startNanoseconds: UInt64
private let startedTask: Task<Void, Never>
private let monotonicClock: any AnalyticsMonotonicClock
private let trigger: any AnalyticsUploadTriggering
private let terminalLock = NSLock()
private var reachedTerminalState = false
init(
repository: AnalyticsRepository,
context: AnalyticsBootstrapContext,
feature: AnalyticsFeature,
executionMode: AnalyticsExecutionMode,
startNanoseconds: UInt64,
startedTask: Task<Void, Never>,
monotonicClock: any AnalyticsMonotonicClock,
trigger: any AnalyticsUploadTriggering
) {
self.repository = repository
self.context = context
self.feature = feature
self.executionMode = executionMode
self.startNanoseconds = startNanoseconds
self.startedTask = startedTask
self.monotonicClock = monotonicClock
self.trigger = trigger
}
public func succeed() {
finish(eventType: .aiFeatureSucceeded, failureCategory: nil)
}
public func fail(category: AnalyticsFailureCategory) {
finish(eventType: .aiFeatureFailed, failureCategory: category)
}
public func cancel() {
fail(category: .cancelled)
}
private func finish(
eventType: AnalyticsEventType,
failureCategory: AnalyticsFailureCategory?
) {
terminalLock.lock()
guard !reachedTerminalState else {
terminalLock.unlock()
return
}
reachedTerminalState = true
terminalLock.unlock()
let endNanoseconds = monotonicClock.nowNanoseconds()
let elapsed = endNanoseconds >= startNanoseconds
? endNanoseconds - startNanoseconds
: 0
let dimensions = AnalyticsEventDimensions(
feature: feature,
executionMode: executionMode,
failureCategory: failureCategory,
durationBucket: AnalyticsDurationBucket(elapsedNanoseconds: elapsed)
)
Task {
// This explicit dependency guarantees STARTED reaches the repository
// before any terminal event, even when completion is immediate.
await startedTask.value
await repository.record(
eventType: eventType,
context: context,
dimensions: dimensions
)
await trigger.requestUpload()
}
}
}
public struct NoopAnalyticsAIOperation: AnalyticsAIOperation {
public init() {}
public func succeed() {}
public func fail(category: AnalyticsFailureCategory) {}
public func cancel() {}
}
public struct NoopAnalyticsClient: AnalyticsClient {
public init() {}
public func recordSessionActivity() {}
public func recordKeyboardActivated() {}
public func recordPurchaseViewed() {}
public func recordPurchaseStarted() {}
public func recordPurchaseCancelled() {}
public func recordReferralShared() {}
public func recordInviteOpened(
acquisitionChannel: AnalyticsAcquisitionChannel,
surface: AnalyticsSurface
) {}
public func startAIFeature(
_ feature: AnalyticsFeature,
executionMode: AnalyticsExecutionMode
) -> any AnalyticsAIOperation {
NoopAnalyticsAIOperation()
}
}
public typealias AnalyticsAISpan = any AnalyticsAIOperation
public struct AnalyticsRuntime: Sendable {
public let repository: AnalyticsRepository
public let client: any AnalyticsClient
public let uploadCoordinator: AnalyticsUploadCoordinator
public let context: AnalyticsBootstrapContext
private init(
surface: AnalyticsSurface,
environment: AnalyticsEnvironment,
repositoryConfiguration: AnalyticsRepositoryConfiguration,
uploadConfiguration: AnalyticsUploadConfiguration,
network: any AnalyticsNetworking,
bearerProvider: (any AnalyticsBearerProviding)?,
wallClock: any AnalyticsWallClock,
monotonicClock: any AnalyticsMonotonicClock,
uuidGenerator: any AnalyticsUUIDGenerating,
random: any AnalyticsRandomGenerating,
trigger: any AnalyticsUploadTriggering,
logger: any AnalyticsLogging
) {
let context = AnalyticsBootstrapContext(
surface: surface,
environment: environment
)
let repository = AnalyticsRepository(
configuration: repositoryConfiguration,
clock: wallClock,
uuidGenerator: uuidGenerator
)
self.context = context
self.repository = repository
client = LiveAnalyticsClient(
repository: repository,
context: context,
monotonicClock: monotonicClock,
trigger: trigger
)
uploadCoordinator = AnalyticsUploadCoordinator(
repository: repository,
configuration: uploadConfiguration,
network: network,
bearerProvider: bearerProvider,
clock: wallClock,
uuidGenerator: uuidGenerator,
random: random,
logger: logger
)
}
/// Main-app runtime. A bearer provider may be supplied by host-only account
/// code without making the shared framework depend on that code.
public static func mainApp(
environment: AnalyticsEnvironment,
repositoryConfiguration: AnalyticsRepositoryConfiguration = .appGroupDefault(),
uploadConfiguration: AnalyticsUploadConfiguration,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
bearerProvider: (any AnalyticsBearerProviding)? = nil,
wallClock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
monotonicClock: any AnalyticsMonotonicClock = SystemAnalyticsMonotonicClock(),
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger(),
logger: any AnalyticsLogging = NoopAnalyticsLogger()
) -> Self {
Self(
surface: .app,
environment: environment,
repositoryConfiguration: repositoryConfiguration,
uploadConfiguration: uploadConfiguration,
network: network,
bearerProvider: bearerProvider,
wallClock: wallClock,
monotonicClock: monotonicClock,
uuidGenerator: uuidGenerator,
random: random,
trigger: trigger,
logger: logger
)
}
/// Keyboard-extension runtime. This factory intentionally has no bearer
/// parameter, so extension uploads are anonymous by construction.
public static func keyboardExtension(
environment: AnalyticsEnvironment,
repositoryConfiguration: AnalyticsRepositoryConfiguration = .appGroupDefault(),
uploadConfiguration: AnalyticsUploadConfiguration,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
wallClock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
monotonicClock: any AnalyticsMonotonicClock = SystemAnalyticsMonotonicClock(),
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger(),
logger: any AnalyticsLogging = NoopAnalyticsLogger()
) -> Self {
Self(
surface: .keyboard,
environment: environment,
repositoryConfiguration: repositoryConfiguration,
uploadConfiguration: uploadConfiguration,
network: network,
bearerProvider: nil,
wallClock: wallClock,
monotonicClock: monotonicClock,
uuidGenerator: uuidGenerator,
random: random,
trigger: trigger,
logger: logger
)
}
public func setEnabled(_ enabled: Bool) async {
await repository.setEnabled(enabled)
}
/// Host-only explicit initialization point. Call after processing the
/// cold-start URL so FIRST_OPEN receives the final acquisition channel.
public func prepare(
firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel = .unknown
) async {
await repository.prepare(
using: context,
firstOpenAcquisitionChannel: firstOpenAcquisitionChannel
)
}
public func isEnabled() async -> Bool {
await repository.isEnabled()
}
public func observeAccount(
stableIdentifier: String
) async -> AnalyticsAccountObservation {
await repository.observeAccount(
stableIdentifier: stableIdentifier
)
}
public func handleAccountDeletion() async {
await repository.handleAccountDeletion()
}
}
@@ -0,0 +1,227 @@
// AnalyticsDependencies.swift
// OSGKeyboard · Shared
//
// Injectable system boundaries used by both the host app and keyboard extension.
import Foundation
public protocol AnalyticsWallClock: Sendable {
func now() -> Date
}
public protocol AnalyticsMonotonicClock: Sendable {
func nowNanoseconds() -> UInt64
}
public protocol AnalyticsUUIDGenerating: Sendable {
func makeUUID() -> UUID
}
public protocol AnalyticsRandomGenerating: Sendable {
/// Returns a value in the closed range 0...upperBound.
func next(upperBound: UInt64) -> UInt64
}
public struct SystemAnalyticsWallClock: AnalyticsWallClock {
public init() {}
public func now() -> Date {
Date()
}
}
public struct SystemAnalyticsMonotonicClock: AnalyticsMonotonicClock {
public init() {}
public func nowNanoseconds() -> UInt64 {
DispatchTime.now().uptimeNanoseconds
}
}
public struct SystemAnalyticsUUIDGenerator: AnalyticsUUIDGenerating {
public init() {}
public func makeUUID() -> UUID {
UUID()
}
}
public final class SystemAnalyticsRandomGenerator: AnalyticsRandomGenerating, @unchecked Sendable {
private let lock = NSLock()
private var generator = SystemRandomNumberGenerator()
public init() {}
public func next(upperBound: UInt64) -> UInt64 {
guard upperBound > 0 else { return 0 }
lock.lock()
defer { lock.unlock() }
return UInt64.random(in: 0...upperBound, using: &generator)
}
}
public struct AnalyticsHTTPRequest: Sendable {
public let url: URL
public let headers: [String: String]
public let body: Data
public init(url: URL, headers: [String: String], body: Data) {
self.url = url
self.headers = headers
self.body = body
}
}
public struct AnalyticsHTTPResponse: Sendable {
public let statusCode: Int
public let headers: [String: String]
public let body: Data
public init(statusCode: Int, headers: [String: String], body: Data) {
self.statusCode = statusCode
self.headers = headers
self.body = body
}
func header(named name: String) -> String? {
headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
}
}
public protocol AnalyticsNetworking: Sendable {
func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse
}
public protocol AnalyticsBearerProviding: Sendable {
func bearerToken() async throws -> String?
func refreshBearerToken(
afterUnauthorizedAccessToken failedToken: String?
) async throws -> String?
}
public protocol AnalyticsUploadTriggering: Sendable {
func requestUpload() async
}
public struct NoopAnalyticsUploadTrigger: AnalyticsUploadTriggering {
public init() {}
public func requestUpload() async {}
}
public enum AnalyticsUploadErrorCategory: String, Sendable {
case network
case timeout
case authentication
case rateLimited
case server
case client
case decoding
case countMismatch
case storage
}
public struct AnalyticsUploadLogEntry: Sendable {
public enum Outcome: String, Sendable {
case uploaded
case retryScheduled
case quarantined
case skipped
}
public let outcome: Outcome
public let eventCount: Int
public let statusCode: Int?
public let attempt: Int
public let errorCategory: AnalyticsUploadErrorCategory?
public init(
outcome: Outcome,
eventCount: Int,
statusCode: Int? = nil,
attempt: Int = 0,
errorCategory: AnalyticsUploadErrorCategory? = nil
) {
self.outcome = outcome
self.eventCount = eventCount
self.statusCode = statusCode
self.attempt = attempt
self.errorCategory = errorCategory
}
}
public protocol AnalyticsLogging: Sendable {
func log(_ entry: AnalyticsUploadLogEntry)
}
public struct NoopAnalyticsLogger: AnalyticsLogging {
public init() {}
public func log(_ entry: AnalyticsUploadLogEntry) {}
}
public struct AnalyticsRepositoryConfiguration: Sendable {
public static let defaultDatabaseFilename = "analytics.sqlite3"
public let databaseURL: URL?
public let maximumEventCount: Int
public let maximumStoredBytes: Int
public let eventRetention: TimeInterval
public let busyTimeoutMilliseconds: Int32
public init(
databaseURL: URL?,
maximumEventCount: Int = 10_000,
maximumStoredBytes: Int = 5 * 1_024 * 1_024,
eventRetention: TimeInterval = 34 * 24 * 60 * 60,
busyTimeoutMilliseconds: Int32 = 2_000
) {
self.databaseURL = databaseURL
self.maximumEventCount = max(1, maximumEventCount)
self.maximumStoredBytes = max(1_024, maximumStoredBytes)
self.eventRetention = max(60, eventRetention)
self.busyTimeoutMilliseconds = max(0, busyTimeoutMilliseconds)
}
public static func appGroupDefault(
appGroupIdentifier: String = AppGroup.identifier
) -> Self {
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupIdentifier
)
let directory = container?.appendingPathComponent(
"Library/Application Support/Analytics",
isDirectory: true
)
return Self(
databaseURL: directory?.appendingPathComponent(defaultDatabaseFilename)
)
}
}
public struct AnalyticsUploadConfiguration: Sendable {
public let endpoint: URL
public let maximumBatchCount: Int
public let maximumBodyBytes: Int
public let globalLeaseDuration: TimeInterval
public let eventLeaseDuration: TimeInterval
public let maximumBackoff: TimeInterval
public init(
endpoint: URL,
maximumBatchCount: Int = 50,
maximumBodyBytes: Int = 60 * 1_024,
globalLeaseDuration: TimeInterval = 2 * 60,
eventLeaseDuration: TimeInterval = 5 * 60,
maximumBackoff: TimeInterval = 6 * 60 * 60
) {
self.endpoint = endpoint
self.maximumBatchCount = min(50, max(1, maximumBatchCount))
self.maximumBodyBytes = min(60 * 1_024, max(1_024, maximumBodyBytes))
// Both leases outlive the default 30-second transport timeout. The
// coordinator also renews them immediately before every request.
self.globalLeaseDuration = max(60, globalLeaseDuration)
self.eventLeaseDuration = max(60, eventLeaseDuration)
self.maximumBackoff = min(6 * 60 * 60, max(60, maximumBackoff))
}
}
@@ -0,0 +1,446 @@
// AnalyticsModels.swift
// OSGKeyboard · Shared
//
// Privacy-preserving analytics wire contract. There is intentionally no
// free-form properties dictionary or user-provided text in this model.
import Foundation
public enum AnalyticsEventType: String, Codable, CaseIterable, Sendable {
case firstOpen = "FIRST_OPEN"
case sessionStarted = "SESSION_STARTED"
case keyboardActivated = "KEYBOARD_ACTIVATED"
case aiFeatureStarted = "AI_FEATURE_STARTED"
case aiFeatureSucceeded = "AI_FEATURE_SUCCEEDED"
case aiFeatureFailed = "AI_FEATURE_FAILED"
case purchaseViewed = "PURCHASE_VIEWED"
case purchaseStarted = "PURCHASE_STARTED"
case purchaseCancelled = "PURCHASE_CANCELLED"
case referralShared = "REFERRAL_SHARED"
case inviteOpened = "INVITE_OPENED"
}
public enum AnalyticsSurface: String, Codable, CaseIterable, Sendable {
case app = "APP"
case keyboard = "KEYBOARD"
case inviteWeb = "INVITE_WEB"
}
public enum AnalyticsAcquisitionChannel: String, Codable, CaseIterable, Sendable {
case appStoreOrganic = "APP_STORE_ORGANIC"
case referral = "REFERRAL"
case socialContent = "SOCIAL_CONTENT"
case unknown = "UNKNOWN"
}
public enum AnalyticsFeature: String, Codable, CaseIterable, Sendable {
case transcription = "TRANSCRIPTION"
case polish = "POLISH"
case aiAssistant = "AI_ASSISTANT"
case agent = "AGENT"
case hotword = "HOTWORD"
case other = "OTHER"
}
public enum AnalyticsExecutionMode: String, Codable, CaseIterable, Sendable {
case managed = "MANAGED"
case local = "LOCAL"
case byok = "BYOK"
}
public enum AnalyticsFailureCategory: String, Codable, CaseIterable, Sendable {
case network = "NETWORK"
case provider = "PROVIDER"
case timeout = "TIMEOUT"
case cancelled = "CANCELLED"
case insufficientCredits = "INSUFFICIENT_CREDITS"
case validation = "VALIDATION"
case unknown = "UNKNOWN"
}
public enum AnalyticsDurationBucket: String, Codable, CaseIterable, Sendable {
case lessThanOneSecond = "LT_1S"
case oneToThreeSeconds = "S1_TO_3"
case threeToTenSeconds = "S3_TO_10"
case tenToThirtySeconds = "S10_TO_30"
case thirtySecondsOrMore = "GTE_30S"
public init(elapsedNanoseconds: UInt64) {
switch elapsedNanoseconds {
case ..<1_000_000_000:
self = .lessThanOneSecond
case ..<3_000_000_000:
self = .oneToThreeSeconds
case ..<10_000_000_000:
self = .threeToTenSeconds
case ..<30_000_000_000:
self = .tenToThirtySeconds
default:
self = .thirtySecondsOrMore
}
}
}
public enum AnalyticsModelError: Error, Sendable {
case unknownField(String)
case invalidDimensions(AnalyticsEventType)
case invalidVersion
case invalidResponseCounts
}
public struct AnalyticsEvent: Codable, Equatable, Sendable {
public let installationId: UUID
public let clientEventId: UUID
public let eventType: AnalyticsEventType
public let occurredAt: Date
public let surface: AnalyticsSurface
public let appVersion: String
public let osVersion: String
public let acquisitionChannel: AnalyticsAcquisitionChannel?
public let feature: AnalyticsFeature?
public let executionMode: AnalyticsExecutionMode?
public let failureCategory: AnalyticsFailureCategory?
public let durationBucket: AnalyticsDurationBucket?
public init(
installationId: UUID,
clientEventId: UUID,
eventType: AnalyticsEventType,
occurredAt: Date,
surface: AnalyticsSurface,
appVersion: String,
osVersion: String,
acquisitionChannel: AnalyticsAcquisitionChannel? = nil,
feature: AnalyticsFeature? = nil,
executionMode: AnalyticsExecutionMode? = nil,
failureCategory: AnalyticsFailureCategory? = nil,
durationBucket: AnalyticsDurationBucket? = nil
) throws {
guard AnalyticsEnvironment.isSafeVersion(appVersion),
AnalyticsEnvironment.isSafeVersion(osVersion) else {
throw AnalyticsModelError.invalidVersion
}
self.installationId = installationId
self.clientEventId = clientEventId
self.eventType = eventType
self.occurredAt = occurredAt
self.surface = surface
self.appVersion = appVersion
self.osVersion = osVersion
self.acquisitionChannel = acquisitionChannel
self.feature = feature
self.executionMode = executionMode
self.failureCategory = failureCategory
self.durationBucket = durationBucket
guard dimensionsAreAllowed else {
throw AnalyticsModelError.invalidDimensions(eventType)
}
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case installationId
case clientEventId
case eventType
case occurredAt
case surface
case appVersion
case osVersion
case acquisitionChannel
case feature
case executionMode
case failureCategory
case durationBucket
}
public init(from decoder: Decoder) throws {
try AnalyticsCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
let installationId = try container.decode(UUID.self, forKey: .installationId)
let clientEventId = try container.decode(UUID.self, forKey: .clientEventId)
let eventType = try container.decode(AnalyticsEventType.self, forKey: .eventType)
let occurredAtText = try container.decode(String.self, forKey: .occurredAt)
guard let occurredAt = AnalyticsWireDate.date(from: occurredAtText) else {
throw DecodingError.dataCorruptedError(
forKey: .occurredAt,
in: container,
debugDescription: "occurredAt must be a UTC ISO-8601 timestamp"
)
}
try self.init(
installationId: installationId,
clientEventId: clientEventId,
eventType: eventType,
occurredAt: occurredAt,
surface: try container.decode(AnalyticsSurface.self, forKey: .surface),
appVersion: try container.decode(String.self, forKey: .appVersion),
osVersion: try container.decode(String.self, forKey: .osVersion),
acquisitionChannel: try container.decodeIfPresent(
AnalyticsAcquisitionChannel.self,
forKey: .acquisitionChannel
),
feature: try container.decodeIfPresent(AnalyticsFeature.self, forKey: .feature),
executionMode: try container.decodeIfPresent(
AnalyticsExecutionMode.self,
forKey: .executionMode
),
failureCategory: try container.decodeIfPresent(
AnalyticsFailureCategory.self,
forKey: .failureCategory
),
durationBucket: try container.decodeIfPresent(
AnalyticsDurationBucket.self,
forKey: .durationBucket
)
)
}
public func encode(to encoder: Encoder) throws {
guard dimensionsAreAllowed else {
throw AnalyticsModelError.invalidDimensions(eventType)
}
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(installationId, forKey: .installationId)
try container.encode(clientEventId, forKey: .clientEventId)
try container.encode(eventType, forKey: .eventType)
try container.encode(AnalyticsWireDate.string(from: occurredAt), forKey: .occurredAt)
try container.encode(surface, forKey: .surface)
try container.encode(appVersion, forKey: .appVersion)
try container.encode(osVersion, forKey: .osVersion)
try container.encodeIfPresent(acquisitionChannel, forKey: .acquisitionChannel)
try container.encodeIfPresent(feature, forKey: .feature)
try container.encodeIfPresent(executionMode, forKey: .executionMode)
try container.encodeIfPresent(failureCategory, forKey: .failureCategory)
try container.encodeIfPresent(durationBucket, forKey: .durationBucket)
}
private var dimensionsAreAllowed: Bool {
let present = DimensionSet(
acquisitionChannel: acquisitionChannel != nil,
feature: feature != nil,
executionMode: executionMode != nil,
failureCategory: failureCategory != nil,
durationBucket: durationBucket != nil
)
guard eventType.allowedDimensionSets.contains(present) else {
return false
}
if eventType == .purchaseCancelled {
return failureCategory == .cancelled
}
return true
}
}
public struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
public let events: [AnalyticsEvent]
public init(events: [AnalyticsEvent]) {
self.events = events
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case events
}
public init(from decoder: Decoder) throws {
try AnalyticsCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
events = try container.decode([AnalyticsEvent].self, forKey: .events)
}
}
public struct AnalyticsUploadResponse: Codable, Equatable, Sendable {
public let accepted: Int
public let replayed: Int
public init(accepted: Int, replayed: Int) throws {
guard accepted >= 0, replayed >= 0 else {
throw AnalyticsModelError.invalidResponseCounts
}
self.accepted = accepted
self.replayed = replayed
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case accepted
case replayed
}
public init(from decoder: Decoder) throws {
try AnalyticsCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
try self.init(
accepted: container.decode(Int.self, forKey: .accepted),
replayed: container.decode(Int.self, forKey: .replayed)
)
}
}
public struct AnalyticsEnvironment: Equatable, Sendable {
public let appVersion: String
public let osVersion: String
public init(appVersion: String, osVersion: String) {
self.appVersion = Self.sanitizedVersion(appVersion)
self.osVersion = Self.sanitizedVersion(osVersion)
}
static func isSafeVersion(_ value: String) -> Bool {
!value.isEmpty
&& value.utf8.count <= 32
&& value.unicodeScalars.allSatisfy {
CharacterSet(charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_")
.contains($0)
}
}
private static func sanitizedVersion(_ value: String) -> String {
let allowed = CharacterSet(
charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_"
)
let filtered = String(value.unicodeScalars.filter(allowed.contains).prefix(32))
return filtered.isEmpty ? "unknown" : filtered
}
}
private struct DimensionSet: Hashable {
let acquisitionChannel: Bool
let feature: Bool
let executionMode: Bool
let failureCategory: Bool
let durationBucket: Bool
static let none = Self(
acquisitionChannel: false,
feature: false,
executionMode: false,
failureCategory: false,
durationBucket: false
)
}
private extension AnalyticsEventType {
var allowedDimensionSets: Set<DimensionSet> {
switch self {
case .firstOpen, .inviteOpened:
return [
DimensionSet(
acquisitionChannel: true,
feature: false,
executionMode: false,
failureCategory: false,
durationBucket: false
)
]
case .aiFeatureStarted:
return [
DimensionSet(
acquisitionChannel: false,
feature: true,
executionMode: true,
failureCategory: false,
durationBucket: false
)
]
case .aiFeatureSucceeded:
return [
DimensionSet(
acquisitionChannel: false,
feature: true,
executionMode: true,
failureCategory: false,
durationBucket: true
)
]
case .aiFeatureFailed:
return [
DimensionSet(
acquisitionChannel: false,
feature: true,
executionMode: true,
failureCategory: true,
durationBucket: true
)
]
case .sessionStarted,
.keyboardActivated,
.purchaseViewed,
.purchaseStarted,
.referralShared:
return [.none]
case .purchaseCancelled:
return [
DimensionSet(
acquisitionChannel: false,
feature: false,
executionMode: false,
failureCategory: true,
durationBucket: false
)
]
}
}
}
enum AnalyticsCanonicalJSON {
static func encode<T: Encodable>(_ value: T) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
return try encoder.encode(value)
}
}
enum AnalyticsWireDate {
static func string(from date: Date) -> String {
formatter.string(from: date)
}
static func date(from value: String) -> Date? {
formatter.date(from: value)
}
private static var formatter: ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter
}
}
private enum AnalyticsCodableAllowlist {
static func rejectUnknownKeys(
in decoder: Decoder,
allowed: Set<String>
) throws {
let container = try decoder.container(keyedBy: AnyCodingKey.self)
if let unknown = container.allKeys.first(where: { !allowed.contains($0.stringValue) }) {
throw AnalyticsModelError.unknownField(unknown.stringValue)
}
}
}
private struct AnyCodingKey: CodingKey {
let stringValue: String
let intValue: Int?
init?(stringValue: String) {
self.stringValue = stringValue
intValue = nil
}
init?(intValue: Int) {
stringValue = String(intValue)
self.intValue = intValue
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,484 @@
// AnalyticsUploadCoordinator.swift
// OSGKeyboard · Shared
//
// Bounded uploader with cross-process leases, retry policy and poison-event
// isolation. Logs never contain payloads, identifiers, endpoints or tokens.
import Foundation
public final class URLSessionAnalyticsNetwork: AnalyticsNetworking, @unchecked Sendable {
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
public func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse {
var urlRequest = URLRequest(url: request.url)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = request.body
urlRequest.timeoutInterval = 30
for (name, value) in request.headers {
urlRequest.setValue(value, forHTTPHeaderField: name)
}
let (body, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw AnalyticsNetworkError.nonHTTPResponse
}
var headers: [String: String] = [:]
for (key, value) in httpResponse.allHeaderFields {
headers[String(describing: key)] = String(describing: value)
}
return AnalyticsHTTPResponse(
statusCode: httpResponse.statusCode,
headers: headers,
body: body
)
}
}
public actor AnalyticsUploadCoordinator {
private struct AuthorizationState {
var token: String?
var didRefresh = false
}
private let repository: AnalyticsRepository
private let configuration: AnalyticsUploadConfiguration
private let network: any AnalyticsNetworking
private let bearerProvider: (any AnalyticsBearerProviding)?
private let clock: any AnalyticsWallClock
private let uuidGenerator: any AnalyticsUUIDGenerating
private let random: any AnalyticsRandomGenerating
private let logger: any AnalyticsLogging
private var uploadInProgress = false
public init(
repository: AnalyticsRepository,
configuration: AnalyticsUploadConfiguration,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
bearerProvider: (any AnalyticsBearerProviding)? = nil,
clock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
logger: any AnalyticsLogging = NoopAnalyticsLogger()
) {
self.repository = repository
self.configuration = configuration
self.network = network
self.bearerProvider = bearerProvider
self.clock = clock
self.uuidGenerator = uuidGenerator
self.random = random
self.logger = logger
}
/// Performs a bounded drain. The default keeps extension execution time and
/// memory predictable while allowing a host app to request more batches.
public func uploadAvailableEvents(maximumBatches: Int = 1) async {
guard !uploadInProgress else { return }
uploadInProgress = true
defer { uploadInProgress = false }
guard configuration.endpoint.scheme?.lowercased() == "https" else {
logger.log(
AnalyticsUploadLogEntry(
outcome: .skipped,
eventCount: 0,
errorCategory: .client
)
)
return
}
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
let batchLimit = max(1, maximumBatches)
for _ in 0..<batchLimit {
guard let batch = await repository.leaseBatch(
ownerID: ownerID,
configuration: configuration
) else {
return
}
var authorization = AuthorizationState()
if let bearerProvider {
do {
authorization.token = try await bearerProvider.bearerToken()
} catch {
await scheduleRetry(
events: batch.events,
leaseID: batch.leaseID,
response: nil,
category: .authentication
)
await repository.releaseGlobalLease(ownerID: ownerID)
return
}
}
await process(
events: batch.events,
leaseID: batch.leaseID,
ownerID: ownerID,
authorization: &authorization
)
await repository.releaseGlobalLease(ownerID: ownerID)
}
}
private func process(
events: [AnalyticsLeasedEvent],
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !events.isEmpty else { return }
guard await renewLease(
events: events,
leaseID: leaseID,
ownerID: ownerID
) else {
return
}
let response: AnalyticsHTTPResponse
do {
response = try await send(events: events, token: authorization.token)
} catch {
await scheduleRetry(
events: events,
leaseID: leaseID,
response: nil,
category: Self.networkCategory(for: error)
)
return
}
if response.statusCode == 401,
!authorization.didRefresh,
let bearerProvider {
authorization.didRefresh = true
let failedToken = authorization.token
do {
authorization.token = try await bearerProvider.refreshBearerToken(
afterUnauthorizedAccessToken: failedToken
)
guard await renewLease(
events: events,
leaseID: leaseID,
ownerID: ownerID
) else {
return
}
let refreshed = try await send(events: events, token: authorization.token)
await processResponse(
refreshed,
events: events,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
} catch {
await scheduleRetry(
events: events,
leaseID: leaseID,
response: response,
category: .authentication,
minimumDelay: configuration.maximumBackoff
)
}
return
}
await processResponse(
response,
events: events,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
}
private func processResponse(
_ response: AnalyticsHTTPResponse,
events: [AnalyticsLeasedEvent],
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
switch response.statusCode {
case 200:
let decoded: AnalyticsUploadResponse
do {
decoded = try JSONDecoder().decode(
AnalyticsUploadResponse.self,
from: response.body
)
} catch {
await scheduleRetry(
events: events,
leaseID: leaseID,
response: response,
category: .decoding
)
return
}
guard decoded.accepted + decoded.replayed == events.count else {
await scheduleRetry(
events: events,
leaseID: leaseID,
response: response,
category: .countMismatch
)
return
}
let completed = await repository.complete(
rowIDs: events.map(\.rowID),
leaseID: leaseID
)
logger.log(
AnalyticsUploadLogEntry(
outcome: completed ? .uploaded : .retryScheduled,
eventCount: events.count,
statusCode: response.statusCode,
attempt: maximumAttempt(in: events),
errorCategory: completed ? nil : .storage
)
)
case 400, 409, 422:
if events.count == 1 {
await repository.quarantine(
rowIDs: [events[0].rowID],
leaseID: leaseID,
reason: "http\(response.statusCode)"
)
logger.log(
AnalyticsUploadLogEntry(
outcome: .quarantined,
eventCount: 1,
statusCode: response.statusCode,
attempt: events[0].attemptCount,
errorCategory: .client
)
)
return
}
let midpoint = events.count / 2
await process(
events: Array(events[..<midpoint]),
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
await process(
events: Array(events[midpoint...]),
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
case 401:
await scheduleRetry(
events: events,
leaseID: leaseID,
response: response,
category: .authentication,
minimumDelay: configuration.maximumBackoff
)
case 408:
await scheduleRetry(
events: events,
leaseID: leaseID,
response: response,
category: .timeout
)
case 429:
await scheduleRetry(
events: events,
leaseID: leaseID,
response: response,
category: .rateLimited
)
case 500...599:
await scheduleRetry(
events: events,
leaseID: leaseID,
response: response,
category: .server
)
case 400...499:
await repository.quarantine(
rowIDs: events.map(\.rowID),
leaseID: leaseID,
reason: "http\(response.statusCode)"
)
logger.log(
AnalyticsUploadLogEntry(
outcome: .quarantined,
eventCount: events.count,
statusCode: response.statusCode,
attempt: maximumAttempt(in: events),
errorCategory: .client
)
)
default:
await scheduleRetry(
events: events,
leaseID: leaseID,
response: response,
category: .server
)
}
}
private func send(
events: [AnalyticsLeasedEvent],
token: String?
) async throws -> AnalyticsHTTPResponse {
var headers = [
"Accept": "application/json",
"Content-Type": "application/json"
]
if let token, !token.isEmpty {
headers["Authorization"] = "Bearer \(token)"
}
return try await network.send(
AnalyticsHTTPRequest(
url: configuration.endpoint,
headers: headers,
body: Self.requestBody(for: events)
)
)
}
private func renewLease(
events: [AnalyticsLeasedEvent],
leaseID: String,
ownerID: String
) async -> Bool {
let renewed = await repository.renewUploadLease(
ownerID: ownerID,
leaseID: leaseID,
globalLeaseDuration: configuration.globalLeaseDuration,
eventLeaseDuration: configuration.eventLeaseDuration
)
guard renewed else {
await repository.releaseEvents(events, leaseID: leaseID)
logger.log(
AnalyticsUploadLogEntry(
outcome: .skipped,
eventCount: events.count,
attempt: maximumAttempt(in: events),
errorCategory: .storage
)
)
return false
}
return true
}
private func scheduleRetry(
events: [AnalyticsLeasedEvent],
leaseID: String,
response: AnalyticsHTTPResponse?,
category: AnalyticsUploadErrorCategory,
minimumDelay: TimeInterval = 0
) async {
let attempt = maximumAttempt(in: events) + 1
let exponent = min(attempt - 1, 16)
let ceiling = min(
configuration.maximumBackoff,
pow(2, Double(exponent))
)
let jitterMilliseconds = random.next(
upperBound: UInt64(max(0, ceiling * 1_000))
)
let jitter = TimeInterval(jitterMilliseconds) / 1_000
let retryAfter = response.flatMap(retryAfterDelay) ?? 0
let delay = min(
configuration.maximumBackoff,
max(minimumDelay, retryAfter, jitter)
)
await repository.scheduleRetry(
events: events,
leaseID: leaseID,
delay: delay
)
logger.log(
AnalyticsUploadLogEntry(
outcome: .retryScheduled,
eventCount: events.count,
statusCode: response?.statusCode,
attempt: attempt,
errorCategory: category
)
)
}
private func retryAfterDelay(_ response: AnalyticsHTTPResponse) -> TimeInterval? {
guard let value = response.header(named: "Retry-After")?
.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
if let seconds = TimeInterval(value) {
return min(configuration.maximumBackoff, max(0, seconds))
}
guard let date = Self.httpDateFormatter.date(from: value) else {
return nil
}
return min(
configuration.maximumBackoff,
max(0, date.timeIntervalSince(clock.now()))
)
}
private func maximumAttempt(in events: [AnalyticsLeasedEvent]) -> Int {
events.map(\.attemptCount).max() ?? 0
}
private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data {
var body = Data(#"{"events":["#.utf8)
for index in events.indices {
if index > 0 {
body.append(UInt8(ascii: ","))
}
body.append(events[index].payload)
}
body.append(Data("]}".utf8))
return body
}
private static func networkCategory(for error: Error) -> AnalyticsUploadErrorCategory {
guard let urlError = error as? URLError else { return .network }
switch urlError.code {
case .timedOut:
return .timeout
case .userAuthenticationRequired,
.userCancelledAuthentication:
return .authentication
default:
return .network
}
}
private static var httpDateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss z"
return formatter
}
}
private enum AnalyticsNetworkError: Error {
case nonHTTPResponse
}