Sources/Confidence/Confidence.swift (405 lines of code) (raw):

// swiftlint:disable file_length import Foundation import Combine import os // swiftlint:disable:next type_body_length public class Confidence: ConfidenceEventSender { // User configurations private let clientSecret: String private let region: ConfidenceRegion private let debugLogger: DebugLogger? // Resources related to managing context and flags private let parentContextProvider: ConfidenceContextProvider? private let contextManager: ContextManager private var cache = FlagResolution.EMPTY // Core components managing internal SDK functionality private let eventSenderEngine: EventSenderEngine private let storage: Storage private let flagApplier: FlagApplier // Synchronization and task management resources private var cancellables = Set<AnyCancellable>() private let cacheQueue = DispatchQueue(label: "com.confidence.queue.cache") private var taskManager = TaskManager() // Internal for testing internal let remoteFlagResolver: ConfidenceResolveClient public static let sdkId: String = "SDK_ID_SWIFT_CONFIDENCE" required init( clientSecret: String, region: ConfidenceRegion, eventSenderEngine: EventSenderEngine, flagApplier: FlagApplier, remoteFlagResolver: ConfidenceResolveClient, storage: Storage, context: ConfidenceStruct = [:], parent: ConfidenceEventSender? = nil, visitorId: String? = nil, debugLogger: DebugLogger? ) { self.eventSenderEngine = eventSenderEngine self.clientSecret = clientSecret self.region = region self.storage = storage self.contextManager = ContextManager(initialContext: context) self.parentContextProvider = parent self.flagApplier = flagApplier self.remoteFlagResolver = remoteFlagResolver self.debugLogger = debugLogger if let visitorId { putContextLocal(context: ["visitor_id": ConfidenceValue.init(string: visitorId)]) } } /** Activating the cache means that the flag data on disk is loaded into memory, so consumers can access flag values. Errors can be thrown if something goes wrong access data on disk. */ public func activate() throws { try cacheQueue.sync { [weak self] in guard let self = self else { return } let savedFlags = try storage.load(defaultValue: FlagResolution.EMPTY) cache = savedFlags } } /** Fetch latest flag evaluations and store them on disk. Regardless of the fetch outcome (success or failure), this function activates the cache after the fetch. Activating the cache means that the flag data on disk is loaded into memory, so consumers can access flag values. Fetching is best-effort, so no error is propagated. Errors can still be thrown if something goes wrong access data on disk. */ public func fetchAndActivate() async throws { await asyncFetch() try activate() } /** Fetch latest flag evaluations and store them on disk. Note that "activate" must be called for this data to be made available in the app session. */ public func asyncFetch() async { do { try await internalFetch() } catch { debugLogger?.logMessage( message: "\(error )", isWarning: true ) } } private func internalFetch() async throws { let context = getContext() let resolvedFlags = try await remoteFlagResolver.resolve(ctx: context) let resolution = FlagResolution( context: context, flags: resolvedFlags.resolvedValues, resolveToken: resolvedFlags.resolveToken ?? "" ) try storage.save(data: resolution) } /** Returns true if any flag is found in storage. */ public func isStorageEmpty() -> Bool { return storage.isEmpty() } /** Get evaluation data for a specific flag. Evaluation data includes the variant's name and reason/error information. - Parameter key:expects dot-notation to retrieve a specific entry in the flag's value, e.g. "flagname.myentry" - Parameter defaultValue: returned in case of errors or in case of the variant's rule indicating to use the default value. */ public func getEvaluation<T>(key: String, defaultValue: T) -> Evaluation<T> { cacheQueue.sync { [weak self] in guard let self = self else { return Evaluation( value: defaultValue, variant: nil, reason: .error, errorCode: .providerNotReady, errorMessage: "Confidence instance deallocated before end of evaluation" ) } return self.cache.evaluate( flagName: key, defaultValue: defaultValue, context: getContext(), flagApplier: flagApplier, debugLogger: debugLogger ) } } /** Get the value for a specific flag. - Parameter key:expects dot-notation to retrieve a specific entry in the flag's value, e.g. "flagname.myentry" - Parameter defaultValue: returned in case of errors or in case of the variant's rule indicating to use the default value. */ public func getValue<T>(key: String, defaultValue: T) -> T { return getEvaluation(key: key, defaultValue: defaultValue).value } public func getContext() -> ConfidenceStruct { let parentContext = parentContextProvider?.getContext() ?? [:] return contextManager.getContext(parentContext: parentContext) } public func putContextAndWait(key: String, value: ConfidenceValue) async { taskManager.currentTask = Task { let newContext = contextManager.updateContext(withValues: [key: value], removedKeys: []) do { try await self.fetchAndActivate() debugLogger?.logContext(action: "PutContext", context: newContext) } catch { debugLogger?.logMessage(message: "Error when putting context: \(error)", isWarning: true) } } await awaitReconciliation() } public func putContextAndWait(context: ConfidenceStruct, removedKeys: [String] = []) async { taskManager.currentTask = Task { let newContext = contextManager.updateContext(withValues: context, removedKeys: removedKeys) do { try await self.fetchAndActivate() debugLogger?.logContext(action: "PutContext", context: newContext) } catch { debugLogger?.logMessage(message: "Error when putting context: \(error)", isWarning: true) } } await awaitReconciliation() } public func putContextAndWait(context: ConfidenceStruct) async { taskManager.currentTask = Task { let newContext = contextManager.updateContext(withValues: context, removedKeys: []) do { try await fetchAndActivate() debugLogger?.logContext( action: "PutContext", context: newContext) } catch { debugLogger?.logMessage( message: "Error when putting context: \(error)", isWarning: true) } } await awaitReconciliation() } public func removeContextAndWait(key: String) async { taskManager.currentTask = Task { let newContext = contextManager.updateContext(withValues: [:], removedKeys: [key]) do { try await self.fetchAndActivate() debugLogger?.logContext( action: "RemoveContext", context: newContext) } catch { debugLogger?.logMessage( message: "Error when removing context key: \(error)", isWarning: true) } } await awaitReconciliation() } /** Adds/override entry to local context data. Does not trigger fetchAndActivate after the context change. */ public func putContextLocal(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) { let newContext = contextManager.updateContext(withValues: context, removedKeys: removedKeys) debugLogger?.logContext( action: "PutContextLocal", context: newContext) } public func putContext(key: String, value: ConfidenceValue) { taskManager.currentTask = Task { await putContextAndWait(key: key, value: value) } } public func putContext(context: ConfidenceStruct) { taskManager.currentTask = Task { await putContextAndWait(context: context) } } public func putContext(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) { taskManager.currentTask = Task { await putContextAndWait(context: context, removedKeys: removedKeys) } } public func removeContext(key: String) { taskManager.currentTask = Task { await removeContextAndWait(key: key) } } public func putContext(context: ConfidenceStruct, removedKeys: [String]) { taskManager.currentTask = Task { let newContext = contextManager.updateContext(withValues: context, removedKeys: removedKeys) do { try await self.fetchAndActivate() debugLogger?.logContext( action: "RemoveContext", context: newContext) } catch { debugLogger?.logMessage( message: "Error when putting context: \(error)", isWarning: true) } } } /** Ensures all the already-started context changes prior to this function have been reconciliated */ public func awaitReconciliation() async { await taskManager.awaitReconciliation() } public func withContext(_ context: ConfidenceStruct) -> ConfidenceEventSender { return Self.init( clientSecret: clientSecret, region: region, eventSenderEngine: eventSenderEngine, flagApplier: flagApplier, remoteFlagResolver: remoteFlagResolver, storage: storage, context: context, parent: self, debugLogger: debugLogger ) } public func track(producer: ConfidenceProducer) { if let eventProducer = producer as? ConfidenceEventProducer { eventProducer.produceEvents() .sink { [weak self] event in guard let self = self else { return } do { try self.track(eventName: event.name, data: event.data) if event.shouldFlush { eventSenderEngine.flush() } } catch { Logger(subsystem: "com.confidence", category: "track").warning( "Error from EventProducer, failed to track event: \(event.name)") } } .store(in: &cancellables) } if let contextProducer = producer as? ConfidenceContextProducer { contextProducer.produceContexts() .sink { [weak self] context in Task { [weak self] in guard let self = self else { return } await self.putContextAndWait(context: context) } } .store(in: &cancellables) } } public func track(eventName: String, data: ConfidenceStruct) throws { try eventSenderEngine.emit( eventName: eventName, data: data, context: getContext() ) } public func flush() { eventSenderEngine.flush() } } private class ContextManager { private var context: ConfidenceStruct = [:] private var removedContextKeys: Set<String> = Set() private let contextQueue = DispatchQueue(label: "com.confidence.queue.context") public init(initialContext: ConfidenceStruct) { context = initialContext } func updateContext(withValues: ConfidenceStruct, removedKeys: [String]) -> ConfidenceStruct { contextQueue.sync { [weak self] in guard let self = self else { return [:] } var map = self.context for removedKey in removedKeys { map.removeValue(forKey: removedKey) removedContextKeys.insert(removedKey) } for entry in withValues { map.updateValue(entry.value, forKey: entry.key) } self.context = map return self.context } } func getContext(parentContext: ConfidenceStruct) -> ConfidenceStruct { contextQueue.sync { [weak self] in guard let self = self else { return [:] } var reconciledCtx = parentContext.filter { !self.removedContextKeys.contains($0.key) } context.forEach { entry in reconciledCtx.updateValue(entry.value, forKey: entry.key) } return reconciledCtx } } } extension Confidence { public class Builder { // Must be configured or configured automatically internal let clientSecret: String internal let eventStorage: EventStorage internal let visitorId = VisitorUtil().getId() internal let loggerLevel: LoggerLevel // Can be configured internal var region: ConfidenceRegion = .global internal var initialContext: ConfidenceStruct = [:] internal var timeout: Double = 10 // Injectable for testing internal var flagApplier: FlagApplier? internal var storage: Storage? internal var flagResolver: ConfidenceResolveClient? internal var debugLogger: DebugLogger? /** Initialize the builder with the given client secret and logger level. The logger allows to print warnings or debugging information to the local console. */ public init(clientSecret: String, loggerLevel: LoggerLevel = .WARN) { self.clientSecret = clientSecret do { eventStorage = try EventStorageImpl() } catch { eventStorage = EventStorageInMemory() } self.loggerLevel = loggerLevel } internal func withFlagResolverClient(flagResolver: ConfidenceResolveClient) -> Builder { self.flagResolver = flagResolver return self } internal func withFlagApplier(flagApplier: FlagApplier) -> Builder { self.flagApplier = flagApplier return self } internal func withStorage(storage: Storage) -> Builder { self.storage = storage return self } internal func withDebugLogger(debugLogger: DebugLogger) -> Builder { self.debugLogger = debugLogger return self } /** Set the initial context. */ public func withContext(initialContext: ConfidenceStruct) -> Builder { self.initialContext = initialContext return self } /** Set the region for the network request to the Confidence backend. The default is `global` and the requests are automatically routed to the closest server. */ public func withRegion(region: ConfidenceRegion) -> Builder { self.region = region return self } /** Set the timeout for the network request, defaulting to 10 seconds. */ public func withTimeout(timeout: Double) -> Builder { self.timeout = timeout return self } /** Build the Confidence instance. */ public func build() -> Confidence { if debugLogger == nil { if loggerLevel != LoggerLevel.NONE { debugLogger = DebugLoggerImpl(loggerLevel: loggerLevel, clientKey: clientSecret) debugLogger?.logContext(action: "InitialContext", context: initialContext) } } let options = ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: clientSecret), region: region, timeoutIntervalForRequest: timeout) let metadata = ConfidenceMetadata( name: sdkId, version: "1.2.0") // x-release-please-version let uploader = RemoteConfidenceClient( options: options, metadata: metadata, debugLogger: debugLogger ) let httpClient = NetworkClient( baseUrl: BaseUrlMapper.from(region: options.region), timeoutIntervalForRequests: options.timeoutIntervalForRequest ) let flagApplier = flagApplier ?? FlagApplierWithRetries( httpClient: httpClient, storage: DefaultStorage(filePath: "confidence.flags.apply"), options: options, metadata: metadata, debugLogger: debugLogger ) let flagResolver = flagResolver ?? RemoteConfidenceResolveClient( options: options, applyOnResolve: false, metadata: metadata ) let eventSenderEngine = EventSenderEngineImpl( clientSecret: clientSecret, uploader: uploader, storage: eventStorage, debugLogger: debugLogger ) return Confidence( clientSecret: clientSecret, region: region, eventSenderEngine: eventSenderEngine, flagApplier: flagApplier, remoteFlagResolver: flagResolver, storage: storage ?? DefaultStorage(filePath: "confidence.flags.resolve"), context: initialContext, parent: nil, visitorId: visitorId, debugLogger: debugLogger ) } } } // swiftlint:enable file_length