feat(app): add resilient Flow recovery and privacy-safe analytics
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:
@@ -40,7 +40,10 @@ public protocol ConfigurationStore: Sendable {
|
||||
/// Provider-specific ASR caches (e.g. Alibaba Fun-ASR vocabulary IDs).
|
||||
var cloudASRPersistence: UserDefaults { get }
|
||||
|
||||
func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient
|
||||
func makeClient(
|
||||
taskKind: ManagedGatewayTaskKind?,
|
||||
requestPurpose: ManagedGatewayRequestPurpose?
|
||||
) -> LLMClient
|
||||
}
|
||||
|
||||
public extension ConfigurationStore {
|
||||
@@ -48,6 +51,10 @@ public extension ConfigurationStore {
|
||||
var credentialSource: CredentialSource { .byok }
|
||||
|
||||
func makeClient() -> LLMClient {
|
||||
makeClient(taskKind: nil)
|
||||
makeClient(taskKind: nil, requestPurpose: nil)
|
||||
}
|
||||
|
||||
func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
|
||||
makeClient(taskKind: taskKind, requestPurpose: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,11 +124,15 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
|
||||
public var detectedAppContext: (context: AppContext, observedAt: Date)? { snapshot.detectedAppContext }
|
||||
public var cloudASRPersistence: UserDefaults { snapshot.cloudASRPersistence }
|
||||
|
||||
public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
|
||||
public func makeClient(
|
||||
taskKind: ManagedGatewayTaskKind?,
|
||||
requestPurpose: ManagedGatewayRequestPurpose?
|
||||
) -> LLMClient {
|
||||
if credentialSource == .managed {
|
||||
return ManagedLLMClient(
|
||||
capability: .polish,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
grants: GatewayGrantCoordinator()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -24,6 +24,12 @@ public enum ManagedGatewayTaskKind: String, Codable, CaseIterable, Sendable {
|
||||
case agentPlanning = "agent_planning"
|
||||
}
|
||||
|
||||
/// Optional server-audited purpose. A purpose may affect billing only when the
|
||||
/// authenticated gateway independently verifies its eligibility.
|
||||
public enum ManagedGatewayRequestPurpose: String, Codable, Sendable {
|
||||
case oobe
|
||||
}
|
||||
|
||||
public struct ManagedGatewayGrantCredentials: Codable, Equatable, Sendable {
|
||||
public static let maximumAccessLifetime: TimeInterval = 5 * 60
|
||||
|
||||
@@ -143,4 +149,5 @@ struct ManagedGatewayTextRequest: Encodable, Sendable {
|
||||
let temperature: Double
|
||||
let stream: Bool
|
||||
let taskKind: ManagedGatewayTaskKind
|
||||
let requestPurpose: ManagedGatewayRequestPurpose?
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ public struct ManagedLLMClient: LLMClient {
|
||||
|
||||
public let capability: Capability
|
||||
public let taskKind: ManagedGatewayTaskKind
|
||||
public let requestPurpose: ManagedGatewayRequestPurpose?
|
||||
public let requestTimeout: TimeInterval
|
||||
|
||||
private let baseURL: URL
|
||||
@@ -60,6 +61,7 @@ public struct ManagedLLMClient: LLMClient {
|
||||
public init(
|
||||
capability: Capability,
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
grants: GatewayGrantCoordinator,
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
session: URLSession = .shared,
|
||||
@@ -68,6 +70,7 @@ public struct ManagedLLMClient: LLMClient {
|
||||
) {
|
||||
self.capability = capability
|
||||
self.taskKind = taskKind ?? capability.defaultTaskKind
|
||||
self.requestPurpose = requestPurpose
|
||||
self.grants = grants
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
@@ -287,7 +290,8 @@ public struct ManagedLLMClient: LLMClient {
|
||||
maxOutputTokens: min(max(attempt.options.maxTokens ?? 512, 1), 4_096),
|
||||
temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1),
|
||||
stream: stream,
|
||||
taskKind: taskKind
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose
|
||||
)
|
||||
|
||||
var request = URLRequest(
|
||||
@@ -419,6 +423,7 @@ public struct ManagedLLMClient: LLMClient {
|
||||
public enum ManagedGatewayLLMClientFactory {
|
||||
public static func polish(
|
||||
taskKind: ManagedGatewayTaskKind = .dictationPolish,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
grants: GatewayGrantCoordinator,
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
session: URLSession = .shared
|
||||
@@ -426,6 +431,7 @@ public enum ManagedGatewayLLMClientFactory {
|
||||
ManagedLLMClient(
|
||||
capability: .polish,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
grants: grants,
|
||||
baseURL: baseURL,
|
||||
session: session
|
||||
|
||||
@@ -193,11 +193,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
public func makeClient(taskKind: ManagedGatewayTaskKind? = nil) -> LLMClient {
|
||||
public func makeClient(
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil
|
||||
) -> LLMClient {
|
||||
if credentialSource == .managed {
|
||||
return ManagedLLMClient(
|
||||
capability: .polish,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
grants: GatewayGrantCoordinator()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
case submitAIQuestion
|
||||
}
|
||||
|
||||
/// Wire version that includes managed-gateway AI task intent.
|
||||
public static let currentProtocolVersion = 6
|
||||
/// Wire version that includes managed-gateway request purpose.
|
||||
public static let currentProtocolVersion = 7
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
@@ -43,6 +43,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
public let aiQuestionText: String?
|
||||
/// Fine-grained managed-gateway intent for AI question submissions.
|
||||
public let aiTaskKind: ManagedGatewayTaskKind?
|
||||
/// Optional server-audited purpose for managed gateway billing policy.
|
||||
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
||||
public let aiThinkingEnabled: Bool?
|
||||
/// Absolute wall-clock deadlines survive extension reconstruction.
|
||||
@@ -65,6 +67,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
aiConversationID: UUID? = nil,
|
||||
aiQuestionText: String? = nil,
|
||||
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
||||
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
aiThinkingEnabled: Bool? = nil,
|
||||
startDeadlineAt: TimeInterval? = nil,
|
||||
processingDeadlineAt: TimeInterval? = nil
|
||||
@@ -84,6 +87,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
self.aiConversationID = aiConversationID
|
||||
self.aiQuestionText = aiQuestionText
|
||||
self.aiTaskKind = aiTaskKind
|
||||
self.managedRequestPurpose = managedRequestPurpose
|
||||
self.aiThinkingEnabled = aiThinkingEnabled
|
||||
self.startDeadlineAt = startDeadlineAt
|
||||
self.processingDeadlineAt = processingDeadlineAt
|
||||
|
||||
@@ -15,6 +15,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
public let aiQuestionText: String?
|
||||
/// Fine-grained managed-gateway intent. Regular questions keep the default.
|
||||
public let aiTaskKind: ManagedGatewayTaskKind?
|
||||
/// Optional server-audited purpose for managed gateway billing policy.
|
||||
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
||||
public let aiThinkingEnabled: Bool?
|
||||
|
||||
@@ -28,6 +30,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
aiConversationID: UUID? = nil,
|
||||
aiQuestionText: String? = nil,
|
||||
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
||||
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
aiThinkingEnabled: Bool? = nil
|
||||
) {
|
||||
self.mode = mode
|
||||
@@ -37,6 +40,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
self.aiConversationID = aiConversationID
|
||||
self.aiQuestionText = aiQuestionText
|
||||
self.aiTaskKind = aiTaskKind
|
||||
self.managedRequestPurpose = managedRequestPurpose
|
||||
self.aiThinkingEnabled = aiThinkingEnabled
|
||||
}
|
||||
|
||||
|
||||
@@ -114,22 +114,33 @@ public struct AIQuestionService: Sendable {
|
||||
private let client: any LLMClient
|
||||
private let conversations: AIConversationStore
|
||||
private let responseLength: AIResponseLength
|
||||
private let analyticsClient: any AnalyticsClient
|
||||
private let analyticsFeature: AnalyticsFeature
|
||||
private let analyticsExecutionMode: AnalyticsExecutionMode
|
||||
|
||||
public init(
|
||||
client: any LLMClient,
|
||||
conversations: AIConversationStore,
|
||||
responseLength: AIResponseLength = .default
|
||||
responseLength: AIResponseLength = .default,
|
||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||
analyticsFeature: AnalyticsFeature = .aiAssistant,
|
||||
analyticsExecutionMode: AnalyticsExecutionMode = .byok
|
||||
) {
|
||||
self.client = client
|
||||
self.conversations = conversations
|
||||
self.responseLength = responseLength
|
||||
self.analyticsClient = analyticsClient
|
||||
self.analyticsFeature = analyticsFeature
|
||||
self.analyticsExecutionMode = analyticsExecutionMode
|
||||
}
|
||||
|
||||
public static func configured(
|
||||
store: any ConfigurationStore,
|
||||
conversations: AIConversationStore,
|
||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||
thinkingEnabled: Bool = true
|
||||
thinkingEnabled: Bool = true,
|
||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||
analyticsFeature: AnalyticsFeature = .aiAssistant
|
||||
) throws -> AIQuestionService {
|
||||
if store.credentialSource == .managed {
|
||||
return AIQuestionService(
|
||||
@@ -139,7 +150,10 @@ public struct AIQuestionService: Sendable {
|
||||
grants: GatewayGrantCoordinator()
|
||||
),
|
||||
conversations: conversations,
|
||||
responseLength: store.aiResponseLength
|
||||
responseLength: store.aiResponseLength,
|
||||
analyticsClient: analyticsClient,
|
||||
analyticsFeature: analyticsFeature,
|
||||
analyticsExecutionMode: .managed
|
||||
)
|
||||
}
|
||||
// Same provider + baseURL + model resolution as dictation polish so the
|
||||
@@ -172,7 +186,10 @@ public struct AIQuestionService: Sendable {
|
||||
thinkingEnabled: thinkingEnabled
|
||||
),
|
||||
conversations: conversations,
|
||||
responseLength: store.aiResponseLength
|
||||
responseLength: store.aiResponseLength,
|
||||
analyticsClient: analyticsClient,
|
||||
analyticsFeature: analyticsFeature,
|
||||
analyticsExecutionMode: .byok
|
||||
)
|
||||
}
|
||||
|
||||
@@ -199,28 +216,38 @@ public struct AIQuestionService: Sendable {
|
||||
maxTokens: Self.outputTokenLimit
|
||||
)
|
||||
|
||||
var accumulated = ""
|
||||
for try await event in client.completeStreaming(
|
||||
messages: messages,
|
||||
timeout: Self.requestTimeout,
|
||||
options: options
|
||||
) {
|
||||
try Task.checkCancellation()
|
||||
switch event {
|
||||
case .delta(let chunk):
|
||||
accumulated += chunk
|
||||
let preview = Self.streamingPreview(accumulated)
|
||||
onPartial?(preview)
|
||||
case .restart:
|
||||
accumulated = ""
|
||||
onPartial?("")
|
||||
let operation = analyticsClient.startAIFeature(
|
||||
analyticsFeature,
|
||||
executionMode: analyticsExecutionMode
|
||||
)
|
||||
do {
|
||||
var accumulated = ""
|
||||
for try await event in client.completeStreaming(
|
||||
messages: messages,
|
||||
timeout: Self.requestTimeout,
|
||||
options: options
|
||||
) {
|
||||
try Task.checkCancellation()
|
||||
switch event {
|
||||
case .delta(let chunk):
|
||||
accumulated += chunk
|
||||
let preview = Self.streamingPreview(accumulated)
|
||||
onPartial?(preview)
|
||||
case .restart:
|
||||
accumulated = ""
|
||||
onPartial?("")
|
||||
}
|
||||
}
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
try Task.checkCancellation()
|
||||
|
||||
let answer = Self.boundedAnswer(accumulated)
|
||||
guard !answer.isEmpty else { throw ServiceError.emptyAnswer }
|
||||
return answer
|
||||
let answer = Self.boundedAnswer(accumulated)
|
||||
guard !answer.isEmpty else { throw ServiceError.emptyAnswer }
|
||||
operation.succeed()
|
||||
return answer
|
||||
} catch {
|
||||
operation.fail(category: Self.analyticsFailureCategory(for: error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit only after the host wins the utterance terminal claim. Keeping
|
||||
@@ -254,6 +281,42 @@ public struct AIQuestionService: Sendable {
|
||||
return String(prefix.dropLast()) + "…"
|
||||
}
|
||||
|
||||
private static func analyticsFailureCategory(
|
||||
for error: Error
|
||||
) -> AnalyticsFailureCategory {
|
||||
if error is CancellationError {
|
||||
return .cancelled
|
||||
}
|
||||
if error is ServiceError {
|
||||
return .validation
|
||||
}
|
||||
if let error = error as? ManagedGatewayError {
|
||||
switch error {
|
||||
case .insufficientCredits:
|
||||
return .insufficientCredits
|
||||
case .timeout:
|
||||
return .timeout
|
||||
case .missingGrant, .scopeNotGranted, .invalidGrant:
|
||||
return .validation
|
||||
case .server:
|
||||
return .provider
|
||||
}
|
||||
}
|
||||
if let error = error as? LLMError {
|
||||
switch error {
|
||||
case .cancelled:
|
||||
return .cancelled
|
||||
case .transport, .rateLimited:
|
||||
return .network
|
||||
case .invalidURL, .noAPIKey, .decoding:
|
||||
return .validation
|
||||
case .http:
|
||||
return .provider
|
||||
}
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
|
||||
/// Soft cap for live drafts — no ellipsis mid-stream.
|
||||
public static func streamingPreview(_ value: String) -> String {
|
||||
if value.count <= AIQuestionLimits.maximumAnswerCharacterCount {
|
||||
|
||||
@@ -394,7 +394,13 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
|
||||
// MARK: - Client
|
||||
|
||||
public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
|
||||
configuration.makeClient(taskKind: taskKind)
|
||||
public func makeClient(
|
||||
taskKind: ManagedGatewayTaskKind?,
|
||||
requestPurpose: ManagedGatewayRequestPurpose?
|
||||
) -> LLMClient {
|
||||
configuration.makeClient(
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ public enum KeyboardSetupBridge {
|
||||
private enum Key {
|
||||
static let fullAccessReady = "keyboard.extension.fullAccessReady"
|
||||
static let lastSeenAt = "keyboard.extension.lastSeenAt"
|
||||
static let onboardingPracticeExpiresAt = "keyboard.onboarding.practiceExpiresAt"
|
||||
static let lastVoiceInsertionAt = "keyboard.extension.lastVoiceInsertionAt"
|
||||
}
|
||||
|
||||
/// True when the keyboard extension last appeared with Full Access enabled.
|
||||
@@ -19,11 +21,72 @@ public enum KeyboardSetupBridge {
|
||||
return AppGroup.defaults.bool(forKey: Key.fullAccessReady)
|
||||
}
|
||||
|
||||
/// True after the extension has appeared at least once. Unlike
|
||||
/// `isReadyForOnboardingSkip`, this also covers an appearance without Full
|
||||
/// Access so the host can explain the missing setting precisely.
|
||||
public static var hasAppeared: Bool {
|
||||
guard AppGroup.isAvailable else { return false }
|
||||
return AppGroup.defaults.double(forKey: Key.lastSeenAt) > 0
|
||||
}
|
||||
|
||||
/// A short-lived exception that lets the real keyboard complete its first
|
||||
/// voice insertion while the host still owns the onboarding screen.
|
||||
public static var isOnboardingPracticeActive: Bool {
|
||||
onboardingPracticeIsActive()
|
||||
}
|
||||
|
||||
/// Wall clock of the most recent voice insertion issued by the extension.
|
||||
/// The host compares this with the current practice start time, so an old
|
||||
/// insertion can never complete a new onboarding run.
|
||||
public static var lastVoiceInsertionAt: Date? {
|
||||
guard AppGroup.isAvailable else { return nil }
|
||||
let value = AppGroup.defaults.double(forKey: Key.lastVoiceInsertionAt)
|
||||
return value > 0 ? Date(timeIntervalSince1970: value) : nil
|
||||
}
|
||||
|
||||
public static func onboardingPracticeIsActive(
|
||||
defaults: UserDefaults? = nil,
|
||||
now: Date = Date()
|
||||
) -> Bool {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return false }
|
||||
return store.double(forKey: Key.onboardingPracticeExpiresAt) > now.timeIntervalSince1970
|
||||
}
|
||||
|
||||
public static func setOnboardingPracticeActive(
|
||||
_ active: Bool,
|
||||
duration: TimeInterval = 30 * 60,
|
||||
defaults: UserDefaults? = nil,
|
||||
now: Date = Date()
|
||||
) {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
|
||||
if active {
|
||||
store.set(
|
||||
now.addingTimeInterval(duration).timeIntervalSince1970,
|
||||
forKey: Key.onboardingPracticeExpiresAt
|
||||
)
|
||||
} else {
|
||||
store.removeObject(forKey: Key.onboardingPracticeExpiresAt)
|
||||
}
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
/// Called from the keyboard extension on each appearance.
|
||||
public static func markExtensionAppearance(hasFullAccess: Bool) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
let defaults = AppGroup.defaults
|
||||
defaults.set(Date().timeIntervalSince1970, forKey: Key.lastSeenAt)
|
||||
defaults.set(hasFullAccess, forKey: Key.fullAccessReady)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
/// Called only after a Flow transcript has been inserted into the host
|
||||
/// field, not when recognition merely produced a result.
|
||||
public static func markVoiceInsertion() {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroup.defaults.set(
|
||||
Date().timeIntervalSince1970,
|
||||
forKey: Key.lastVoiceInsertionAt
|
||||
)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,9 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var flowSessionActive: Bool = false
|
||||
/// Unified mic color / tap / hint source for the keyboard extension.
|
||||
@Published public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady)
|
||||
/// Short-lived host-owned practice mode. It unlocks real dictation before
|
||||
/// onboarding completion, but only while the onboarding text field is live.
|
||||
@Published public var isOnboardingPracticeActive: Bool = false
|
||||
/// When true, the mic is intentionally disabled (e.g. cloud engine
|
||||
/// selected but the provider-specific API key is missing).
|
||||
@Published public var micDisabled: Bool = false
|
||||
|
||||
@@ -54,6 +54,7 @@ public actor PolishingService {
|
||||
let systemPrompt: String?
|
||||
let providerIdOverride: String?
|
||||
let taskKind: ManagedGatewayTaskKind?
|
||||
let requestPurpose: ManagedGatewayRequestPurpose?
|
||||
let context: PolishContext?
|
||||
}
|
||||
|
||||
@@ -79,6 +80,7 @@ public actor PolishingService {
|
||||
|
||||
private let store: any ConfigurationStore
|
||||
private let timeout: TimeInterval
|
||||
private let analyticsClient: any AnalyticsClient
|
||||
/// Optional injected client (mostly for testing). When nil we build
|
||||
/// one from `store.makeClient()` per call.
|
||||
private let injectedClient: LLMClient?
|
||||
@@ -91,11 +93,13 @@ public actor PolishingService {
|
||||
public init(
|
||||
store: any ConfigurationStore = AppGroupStore(),
|
||||
client: LLMClient? = nil,
|
||||
timeout: TimeInterval? = nil
|
||||
timeout: TimeInterval? = nil,
|
||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
|
||||
) {
|
||||
self.store = store
|
||||
self.injectedClient = client
|
||||
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
|
||||
self.analyticsClient = analyticsClient
|
||||
}
|
||||
|
||||
/// Context-aware polish entry point. The optional
|
||||
@@ -110,6 +114,7 @@ public actor PolishingService {
|
||||
systemPrompt: String? = nil,
|
||||
providerIdOverride: String? = nil,
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
context: PolishContext? = nil
|
||||
) async throws -> String {
|
||||
try await performPolish(
|
||||
@@ -119,6 +124,7 @@ public actor PolishingService {
|
||||
systemPrompt: systemPrompt,
|
||||
providerIdOverride: providerIdOverride,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
context: context
|
||||
)
|
||||
).text
|
||||
@@ -132,6 +138,7 @@ public actor PolishingService {
|
||||
systemPrompt: String? = nil,
|
||||
providerIdOverride: String? = nil,
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
context: PolishContext? = nil
|
||||
) async throws -> PolishOutcome {
|
||||
try await performPolish(
|
||||
@@ -141,6 +148,7 @@ public actor PolishingService {
|
||||
systemPrompt: systemPrompt,
|
||||
providerIdOverride: providerIdOverride,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
context: context
|
||||
)
|
||||
)
|
||||
@@ -152,6 +160,7 @@ public actor PolishingService {
|
||||
let systemPrompt = request.systemPrompt
|
||||
let providerIdOverride = request.providerIdOverride
|
||||
let taskKind = request.taskKind
|
||||
let requestPurpose = request.requestPurpose
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
||||
|
||||
@@ -185,14 +194,26 @@ public actor PolishingService {
|
||||
}
|
||||
}
|
||||
|
||||
let remoteResult = try await polishRemote(
|
||||
trimmed,
|
||||
mode: mode,
|
||||
systemPrompt: systemPrompt,
|
||||
providerIdOverride: providerIdOverride,
|
||||
taskKind: taskKind,
|
||||
context: resolvedContext
|
||||
let operation = analyticsClient.startAIFeature(
|
||||
.polish,
|
||||
executionMode: analyticsExecutionMode
|
||||
)
|
||||
let remoteResult: RemotePolishResult
|
||||
do {
|
||||
remoteResult = try await polishRemote(
|
||||
trimmed,
|
||||
mode: mode,
|
||||
systemPrompt: systemPrompt,
|
||||
providerIdOverride: providerIdOverride,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
context: resolvedContext
|
||||
)
|
||||
operation.succeed()
|
||||
} catch {
|
||||
operation.fail(category: Self.analyticsFailureCategory(for: error))
|
||||
throw error
|
||||
}
|
||||
|
||||
// Translation and custom prompts bypass the polish post-processor.
|
||||
if mode != .polish || (systemPrompt != nil && !(systemPrompt?.isEmpty ?? true)) {
|
||||
@@ -214,6 +235,51 @@ public actor PolishingService {
|
||||
return override
|
||||
}
|
||||
|
||||
private var analyticsExecutionMode: AnalyticsExecutionMode {
|
||||
store.credentialSource == .managed ? .managed : .byok
|
||||
}
|
||||
|
||||
private static func analyticsFailureCategory(
|
||||
for error: Error
|
||||
) -> AnalyticsFailureCategory {
|
||||
if error is CancellationError {
|
||||
return .cancelled
|
||||
}
|
||||
if let error = error as? PolishError {
|
||||
switch error {
|
||||
case .timeout:
|
||||
return .timeout
|
||||
case .noTranscript, .missingAPIKey, .keychainLocked:
|
||||
return .validation
|
||||
}
|
||||
}
|
||||
if let error = error as? ManagedGatewayError {
|
||||
switch error {
|
||||
case .insufficientCredits:
|
||||
return .insufficientCredits
|
||||
case .timeout:
|
||||
return .timeout
|
||||
case .missingGrant, .scopeNotGranted, .invalidGrant:
|
||||
return .validation
|
||||
case .server:
|
||||
return .provider
|
||||
}
|
||||
}
|
||||
if let error = error as? LLMError {
|
||||
switch error {
|
||||
case .cancelled:
|
||||
return .cancelled
|
||||
case .transport, .rateLimited:
|
||||
return .network
|
||||
case .invalidURL, .noAPIKey, .decoding:
|
||||
return .validation
|
||||
case .http:
|
||||
return .provider
|
||||
}
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
|
||||
static func managedGatewayTaskKind(for mode: PolishMode) -> ManagedGatewayTaskKind {
|
||||
switch mode {
|
||||
case .polish:
|
||||
@@ -229,6 +295,7 @@ public actor PolishingService {
|
||||
systemPrompt: String? = nil,
|
||||
providerIdOverride: String? = nil,
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
context: PolishContext
|
||||
) async throws -> RemotePolishResult {
|
||||
let effectiveProviderId = Self.resolvedProviderId(
|
||||
@@ -240,7 +307,8 @@ public actor PolishingService {
|
||||
client = injectedClient
|
||||
} else if store.credentialSource == .managed {
|
||||
client = store.makeClient(
|
||||
taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode)
|
||||
taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode),
|
||||
requestPurpose: requestPurpose
|
||||
)
|
||||
} else {
|
||||
let preset = LLMProvider.provider(id: effectiveProviderId)
|
||||
|
||||
Reference in New Issue
Block a user