From eab0a3add3f830742d89a2f61990920097f8f640 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 25 Jun 2026 20:54:35 +0200 Subject: [PATCH 01/13] v3 --- Makefile | 6 +- README.md | 91 +++++--- Sources/Featurevisor/Diagnostics.swift | 46 ++++ Sources/Featurevisor/Emitter.swift | 1 + Sources/Featurevisor/Evaluate.swift | 57 +++-- Sources/Featurevisor/Events.swift | 4 +- Sources/Featurevisor/Hooks.swift | 92 -------- Sources/Featurevisor/Instance.swift | 196 ++++++++++++++++-- Sources/Featurevisor/Models.swift | 3 + Sources/Featurevisor/Modules.swift | 152 ++++++++++++++ Sources/FeaturevisorCLI/CLI.swift | 2 +- .../Commands/AssessDistributionCommand.swift | 1 - .../Commands/BenchmarkCommand.swift | 1 - .../Commands/TestCommand.swift | 124 +++-------- .../FeaturevisorCLI/Support/CLIHelpers.swift | 5 +- .../FeaturevisorCLITests.swift | 12 ++ Tests/FeaturevisorTests/EventsTests.swift | 3 +- Tests/FeaturevisorTests/InstanceTests.swift | 51 +++++ Tests/FeaturevisorTests/ModulesTests.swift | 106 ++++++++++ .../ParityBehaviorTests.swift | 5 +- 20 files changed, 696 insertions(+), 262 deletions(-) create mode 100644 Sources/Featurevisor/Diagnostics.swift delete mode 100644 Sources/Featurevisor/Hooks.swift create mode 100644 Sources/Featurevisor/Modules.swift create mode 100644 Tests/FeaturevisorTests/ModulesTests.swift diff --git a/Makefile b/Makefile index c054121..878986e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install build test clean setup-monorepo update-monorepo setup-golang-sdk update-golang-sdk setup-references update-references +.PHONY: install build test test-example-1 clean setup-monorepo update-monorepo setup-golang-sdk update-golang-sdk setup-references update-references install: swift package update @@ -9,6 +9,10 @@ build: test: swift test +test-example-1: + swift test + swift run featurevisor test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures + clean: swift package clean diff --git a/README.md b/README.md index 577274a..583ba30 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Featurevisor Swift SDK -This is a port of Featurevisor [Javascript SDK](https://featurevisor.com/docs/sdks/javascript/) v2.x to Swift, providing a way to evaluate feature flags, variations, and variables in your Swift applications. +This is a port of Featurevisor [Javascript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to Swift, providing a way to evaluate feature flags, variations, and variables in your Swift applications. -This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 projects and above. +This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 projects and v2 datafiles. ## Table of contents @@ -29,14 +29,15 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 proje - [Levels](#levels) - [Customizing levels](#customizing-levels) - [Handler](#handler) +- [Diagnostics](#diagnostics) - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - [`sticky_set`](#sticky_set) - [Evaluation details](#evaluation-details) -- [Hooks](#hooks) - - [Defining a hook](#defining-a-hook) - - [Registering hooks](#registering-hooks) +- [Modules](#modules) + - [Defining a module](#defining-a-module) + - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) - [CLI usage](#cli-usage) @@ -303,6 +304,13 @@ You can also set using raw JSON string: f.setDatafile(json: jsonString) ``` +By default, `setDatafile` merges the incoming datafile into the SDK's stored datafile. Pass `replace: true` to replace the stored datafile instead: + +```swift +f.setDatafile(datafileContent, replace: true) +f.setDatafile(json: jsonString, replace: true) +``` + ### Updating datafile You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile_set) event that you can listen and react to accordingly. @@ -370,6 +378,22 @@ let f = createInstance( ) ``` +## Diagnostics + +You can observe SDK and module diagnostics with `onDiagnostic`: + +```swift +let f = createInstance( + InstanceOptions( + onDiagnostic: { diagnostic in + print(diagnostic.level, diagnostic.code, diagnostic.message) + } + ) +) +``` + +Modules can also subscribe to diagnostics or report their own diagnostics from `setup` using the provided module API. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to runtime events. @@ -414,35 +438,55 @@ let variationDetails = f.evaluateVariation("my_feature") let variableDetails = f.evaluateVariable("my_feature", "my_variable") ``` -## Hooks +## Modules -Hooks allow you to intercept evaluation inputs and outputs. +Modules allow you to intercept evaluation inputs and outputs. -### Defining a hook +### Defining a module ```swift -let hook = Hook( - name: "my-hook", - before: { input in - input +let module = FeaturevisorModule( + name: "my-module", + setup: { api in + api.reportDiagnostic( + FeaturevisorDiagnostic( + level: .info, + code: "module_ready", + message: "Module is ready" + ) + ) + }, + before: { options in + var updated = options + updated.dependencies.context["someAdditionalAttribute"] = .string("value") + return updated + }, + bucketKey: { options in + options.bucketKey + }, + bucketValue: { options in + options.bucketValue }, after: { evaluation, _ in evaluation + }, + close: { + // clean up module resources } ) ``` -### Registering hooks +### Registering modules ```swift let f = createInstance( InstanceOptions( - hooks: [hook] + modules: [module] ) ) -let removeHook = f.addHook(hook) -removeHook() +let removeModule = f.addModule(module) +removeModule?() ``` ## Child instance @@ -476,15 +520,6 @@ swift run featurevisor test \ --projectDirectoryPath=/path/to/featurevisor-project ``` -With scoped and tagged datafiles: - -```bash -swift run featurevisor test \ - --projectDirectoryPath=/path/to/featurevisor-project \ - --with-scopes \ - --with-tags -``` - ### Benchmark ```bash @@ -517,6 +552,12 @@ swift run featurevisor assess-distribution \ swift test ``` +To verify against the local Featurevisor example-1 project: + +```bash +make test-example-1 +``` + ## License MIT © [Fahad Heylaal](https://fahad19.com) diff --git a/Sources/Featurevisor/Diagnostics.swift b/Sources/Featurevisor/Diagnostics.swift new file mode 100644 index 0000000..0f42540 --- /dev/null +++ b/Sources/Featurevisor/Diagnostics.swift @@ -0,0 +1,46 @@ +import Foundation + +public struct FeaturevisorDiagnostic: Sendable { + public var level: LogLevel + public var code: String + public var message: String + public var module: String? + public var moduleName: String? + public var originalError: String? + public var details: [String: AnyValue] + + public init( + level: LogLevel, + code: String, + message: String, + module: String? = nil, + moduleName: String? = nil, + originalError: String? = nil, + details: [String: AnyValue] = [:] + ) { + self.level = level + self.code = code + self.message = message + self.module = module + self.moduleName = moduleName + self.originalError = originalError + self.details = details + } +} + +public typealias FeaturevisorModuleReportedDiagnostic = FeaturevisorDiagnostic +public typealias FeaturevisorDiagnosticHandler = @Sendable (_ diagnostic: FeaturevisorDiagnostic) -> Void +public typealias FeaturevisorUnsubscribe = () -> Void +public typealias FeaturevisorModuleOnDiagnostic = @Sendable (_ handler: @escaping FeaturevisorDiagnosticHandler, _ options: FeaturevisorModuleDiagnosticOptions) -> FeaturevisorUnsubscribe + +public struct FeaturevisorModuleDiagnosticOptions: Sendable { + public var logLevel: LogLevel + + public init(logLevel: LogLevel = .info) { + self.logLevel = logLevel + } +} + +func shouldLogDiagnostic(currentLevel: LogLevel, diagnosticLevel: LogLevel) -> Bool { + diagnosticLevel.rawValue >= currentLevel.rawValue +} diff --git a/Sources/Featurevisor/Emitter.swift b/Sources/Featurevisor/Emitter.swift index f0ed20b..16698ea 100644 --- a/Sources/Featurevisor/Emitter.swift +++ b/Sources/Featurevisor/Emitter.swift @@ -4,6 +4,7 @@ public enum EventName: String, Sendable { case datafileSet = "datafile_set" case contextSet = "context_set" case stickySet = "sticky_set" + case error } public struct EventPayload: Sendable { diff --git a/Sources/Featurevisor/Evaluate.swift b/Sources/Featurevisor/Evaluate.swift index be79560..c048212 100644 --- a/Sources/Featurevisor/Evaluate.swift +++ b/Sources/Featurevisor/Evaluate.swift @@ -3,7 +3,7 @@ import Foundation public struct EvaluateDependencies: Sendable { public var context: Context public var logger: Logger - public var hooksManager: HooksManager + public var modulesManager: ModulesManager public var datafileReader: DatafileReader public var sticky: StickyFeatures? public var defaultVariationValue: VariationValue? @@ -12,7 +12,7 @@ public struct EvaluateDependencies: Sendable { public init( context: Context, logger: Logger, - hooksManager: HooksManager, + modulesManager: ModulesManager, datafileReader: DatafileReader, sticky: StickyFeatures? = nil, defaultVariationValue: VariationValue? = nil, @@ -20,7 +20,7 @@ public struct EvaluateDependencies: Sendable { ) { self.context = context self.logger = logger - self.hooksManager = hooksManager + self.modulesManager = modulesManager self.datafileReader = datafileReader self.sticky = sticky self.defaultVariationValue = defaultVariationValue @@ -42,17 +42,13 @@ public struct EvaluateOptions: Sendable { } } -public func evaluateWithHooks(_ options: EvaluateOptions) -> Evaluation { - var input = EvaluateInput( - type: options.type, - featureKey: options.featureKey, - variableKey: options.variableKey, - context: options.dependencies.context - ) - - input = options.dependencies.hooksManager.runBefore(input) +public func evaluateWithModules(_ options: EvaluateOptions) -> Evaluation { var updated = options - updated.dependencies.context = input.context + for module in updated.dependencies.modulesManager.getAll() { + if let before = module.before { + updated = before(updated) + } + } var evaluation = evaluate(updated) @@ -66,7 +62,13 @@ public func evaluateWithHooks(_ options: EvaluateOptions) -> Evaluation { evaluation.variableValue = defaultVariable } - return updated.dependencies.hooksManager.runAfter(evaluation, input: input) + for module in updated.dependencies.modulesManager.getAll() { + if let after = module.after { + evaluation = after(evaluation, updated) + } + } + + return evaluation } public func evaluate(_ options: EvaluateOptions) -> Evaluation { @@ -75,7 +77,7 @@ public func evaluate(_ options: EvaluateOptions) -> Evaluation { let variableKey = options.variableKey let context = options.dependencies.context let datafileReader = options.dependencies.datafileReader - let hooksManager = options.dependencies.hooksManager + let modulesManager = options.dependencies.modulesManager let logger = options.dependencies.logger do { @@ -221,8 +223,29 @@ public func evaluate(_ options: EvaluateOptions) -> Evaluation { } let rawBucketKey = getBucketKey(featureKey: featureKey, bucketBy: feature.bucketBy, context: context) ?? featureKey - let bucketKey = hooksManager.transformBucketKey(rawBucketKey) - let bucketValue = hooksManager.transformBucketValue(getBucketedNumber(bucketKey)) + var bucketKey = rawBucketKey + for module in modulesManager.getAll() { + if let transform = module.bucketKey { + bucketKey = transform(ConfigureBucketKeyOptions( + featureKey: featureKey, + context: context, + bucketBy: feature.bucketBy, + bucketKey: bucketKey + )) + } + } + + var bucketValue = getBucketedNumber(bucketKey) + for module in modulesManager.getAll() { + if let transform = module.bucketValue { + bucketValue = transform(ConfigureBucketValueOptions( + featureKey: featureKey, + bucketKey: bucketKey, + context: context, + bucketValue: bucketValue + )) + } + } let matchedTraffic = datafileReader.getMatchedTraffic(feature.traffic, context: context) let matchedAllocation = matchedTraffic.flatMap { datafileReader.getMatchedAllocation($0, bucketValue: bucketValue) } diff --git a/Sources/Featurevisor/Events.swift b/Sources/Featurevisor/Events.swift index 948e415..b33b58d 100644 --- a/Sources/Featurevisor/Events.swift +++ b/Sources/Featurevisor/Events.swift @@ -17,7 +17,8 @@ public func getParamsForStickySetEvent( public func getParamsForDatafileSetEvent( previousDatafileReader: DatafileReader, - newDatafileReader: DatafileReader + newDatafileReader: DatafileReader, + replace: Bool ) -> [String: AnyValue] { let previousRevision = previousDatafileReader.getRevision() let previousFeatureKeys = Set(previousDatafileReader.getFeatureKeys()) @@ -44,5 +45,6 @@ public func getParamsForDatafileSetEvent( "previousRevision": .string(previousRevision), "revisionChanged": .bool(previousRevision != newRevision), "features": .array(affected.map { .string($0) }), + "replaced": .bool(replace), ] } diff --git a/Sources/Featurevisor/Hooks.swift b/Sources/Featurevisor/Hooks.swift deleted file mode 100644 index c85452b..0000000 --- a/Sources/Featurevisor/Hooks.swift +++ /dev/null @@ -1,92 +0,0 @@ -import Foundation - -public struct EvaluateInput: Sendable { - public var type: EvaluationType - public var featureKey: FeatureKey - public var variableKey: VariableKey? - public var context: Context - - public init(type: EvaluationType, featureKey: FeatureKey, variableKey: VariableKey? = nil, context: Context) { - self.type = type - self.featureKey = featureKey - self.variableKey = variableKey - self.context = context - } -} - -public struct BucketContext: Sendable { - public var bucketKey: String - public var bucketValue: Int -} - -public struct Hook: Sendable { - public var name: String - public var before: (@Sendable (EvaluateInput) -> EvaluateInput)? - public var after: (@Sendable (Evaluation, EvaluateInput) -> Evaluation)? - public var bucketKey: (@Sendable (String) -> String)? - public var bucketValue: (@Sendable (Int) -> Int)? - - public init( - name: String, - before: (@Sendable (EvaluateInput) -> EvaluateInput)? = nil, - after: (@Sendable (Evaluation, EvaluateInput) -> Evaluation)? = nil, - bucketKey: (@Sendable (String) -> String)? = nil, - bucketValue: (@Sendable (Int) -> Int)? = nil - ) { - self.name = name - self.before = before - self.after = after - self.bucketKey = bucketKey - self.bucketValue = bucketValue - } -} - -public final class HooksManager: @unchecked Sendable { - private var hooks: [Hook] - private let logger: Logger - - public init(hooks: [Hook], logger: Logger) { - self.hooks = hooks - self.logger = logger - } - - @discardableResult - public func add(_ hook: Hook) -> () -> Void { - hooks.append(hook) - return { [weak self] in - self?.hooks.removeAll(where: { $0.name == hook.name }) - } - } - - public func getAll() -> [Hook] { - hooks - } - - public func runBefore(_ input: EvaluateInput) -> EvaluateInput { - hooks.reduce(input) { partial, hook in - if let before = hook.before { return before(partial) } - return partial - } - } - - public func runAfter(_ evaluation: Evaluation, input: EvaluateInput) -> Evaluation { - hooks.reduce(evaluation) { partial, hook in - if let after = hook.after { return after(partial, input) } - return partial - } - } - - public func transformBucketKey(_ key: String) -> String { - hooks.reduce(key) { partial, hook in - if let transform = hook.bucketKey { return transform(partial) } - return partial - } - } - - public func transformBucketValue(_ value: Int) -> Int { - hooks.reduce(value) { partial, hook in - if let transform = hook.bucketValue { return transform(partial) } - return partial - } - } -} diff --git a/Sources/Featurevisor/Instance.swift b/Sources/Featurevisor/Instance.swift index 9119365..3a0236d 100644 --- a/Sources/Featurevisor/Instance.swift +++ b/Sources/Featurevisor/Instance.swift @@ -5,67 +5,125 @@ public struct InstanceOptions: Sendable { public var context: Context public var logLevel: LogLevel public var logger: Logger? + public var onDiagnostic: FeaturevisorDiagnosticHandler? public var sticky: StickyFeatures? - public var hooks: [Hook] + public var modules: [FeaturevisorModule] public init( datafile: DatafileContent? = nil, context: Context = [:], logLevel: LogLevel = Logger.defaultLevel, logger: Logger? = nil, + onDiagnostic: FeaturevisorDiagnosticHandler? = nil, sticky: StickyFeatures? = nil, - hooks: [Hook] = [] + modules: [FeaturevisorModule] = [] ) { self.datafile = datafile self.context = context self.logLevel = logLevel self.logger = logger + self.onDiagnostic = onDiagnostic self.sticky = sticky - self.hooks = hooks + self.modules = modules } } private let emptyDatafile = DatafileContent(schemaVersion: "2", revision: "unknown", segments: [:], features: [:]) +private struct ModuleDiagnosticSubscription: Sendable { + let id: UUID + let moduleID: UUID + let handler: FeaturevisorDiagnosticHandler + let logLevel: LogLevel +} + public final class FeaturevisorInstance: @unchecked Sendable { private var context: Context private let logger: Logger + private var logLevel: LogLevel + private let onDiagnostic: FeaturevisorDiagnosticHandler? private var sticky: StickyFeatures? + private var datafile: DatafileContent private var datafileReader: DatafileReader - private let hooksManager: HooksManager + private var modulesManager: ModulesManager! + private var moduleDiagnosticSubscriptions: [ModuleDiagnosticSubscription] = [] private let emitter: Emitter + private var closed = false public init(options: InstanceOptions) { self.context = options.context self.logger = options.logger ?? createLogger(level: options.logLevel) + self.logLevel = options.logLevel + self.onDiagnostic = options.onDiagnostic self.sticky = options.sticky - self.hooksManager = HooksManager(hooks: options.hooks, logger: self.logger) self.emitter = Emitter() - self.datafileReader = DatafileReader(datafile: options.datafile ?? emptyDatafile, logger: self.logger) - self.logger.info("Featurevisor SDK initialized") + self.datafile = emptyDatafile + self.datafileReader = DatafileReader(datafile: emptyDatafile, logger: self.logger) + + self.modulesManager = ModulesManager( + modules: options.modules, + reportDiagnostic: { [weak self] diagnostic, sourceModule in + self?.reportDiagnostic(diagnostic, sourceModule: sourceModule) + }, + getModuleApi: { [weak self] module in + self?.getModuleApi(module) ?? FeaturevisorModuleApi( + getRevision: { "unknown" }, + onDiagnostic: { _, _ in {} }, + reportDiagnostic: { _ in } + ) + }, + clearModuleDiagnosticSubscriptions: { [weak self] module in + self?.clearModuleDiagnosticSubscriptions(module) + } + ) + + if let datafile = options.datafile { + setDatafile(datafile, replace: true) + } + + reportDiagnostic(FeaturevisorDiagnostic(level: .info, code: "sdk_initialized", message: "SDK initialized")) } public func setLogLevel(_ level: LogLevel) { + logLevel = level logger.setLevel(level) } - public func setDatafile(_ datafile: DatafileContent) { - let newDatafileReader = DatafileReader(datafile: datafile, logger: logger) - let details = getParamsForDatafileSetEvent(previousDatafileReader: datafileReader, newDatafileReader: newDatafileReader) + public func setDatafile(_ datafile: DatafileContent, replace: Bool = false) { + guard !closed else { return } + + let storedDatafile = replace ? datafile : mergeStoredDatafile(existing: self.datafile, incoming: datafile) + let newDatafileReader = DatafileReader(datafile: storedDatafile, logger: logger) + let details = getParamsForDatafileSetEvent( + previousDatafileReader: datafileReader, + newDatafileReader: newDatafileReader, + replace: replace + ) + + self.datafile = storedDatafile self.datafileReader = newDatafileReader + + reportDiagnostic(FeaturevisorDiagnostic(level: .info, code: "datafile_set", message: "Datafile set", details: details)) emitter.trigger(.datafileSet, payload: EventPayload(details)) } - public func setDatafile(json: String) { + public func setDatafile(json: String, replace: Bool = false) { do { - setDatafile(try DatafileContent.fromJSON(json)) + setDatafile(try DatafileContent.fromJSON(json), replace: replace) } catch { - logger.error("could not parse datafile", details: ["error": error.localizedDescription]) + reportDiagnostic(FeaturevisorDiagnostic( + level: .error, + code: "invalid_datafile", + message: "Could not parse datafile", + originalError: error.localizedDescription + )) } } public func setSticky(_ sticky: StickyFeatures, replace: Bool = false) { + guard !closed else { return } + let previousSticky = self.sticky ?? [:] if replace { self.sticky = sticky @@ -73,6 +131,7 @@ public final class FeaturevisorInstance: @unchecked Sendable { self.sticky = (self.sticky ?? [:]).merging(sticky, uniquingKeysWith: { _, new in new }) } let payload = getParamsForStickySetEvent(previousStickyFeatures: previousSticky, newStickyFeatures: self.sticky ?? [:], replace: replace) + reportDiagnostic(FeaturevisorDiagnostic(level: .info, code: "sticky_set", message: "Sticky features set", details: payload)) emitter.trigger(.stickySet, payload: EventPayload(payload)) } @@ -80,18 +139,105 @@ public final class FeaturevisorInstance: @unchecked Sendable { public func getFeature(_ featureKey: String) -> Feature? { datafileReader.getFeature(featureKey) } @discardableResult - public func addHook(_ hook: Hook) -> () -> Void { hooksManager.add(hook) } + public func addModule(_ module: FeaturevisorModule) -> FeaturevisorUnsubscribe? { + guard !closed else { return nil } + return modulesManager.add(module) + } + + public func removeModule(_ name: String) { + guard !closed else { return } + modulesManager.remove(name) + } @discardableResult - public func on(_ eventName: EventName, callback: @escaping EventCallback) -> () -> Void { - emitter.on(eventName, callback: callback) + public func on(_ eventName: EventName, callback: @escaping EventCallback) -> FeaturevisorUnsubscribe { + guard !closed else { return {} } + return emitter.on(eventName, callback: callback) } public func close() { + guard !closed else { return } + closed = true + modulesManager.closeAll() + moduleDiagnosticSubscriptions = [] emitter.clearAll() } + private func reportDiagnostic(_ diagnostic: FeaturevisorDiagnostic, sourceModule: FeaturevisorModule? = nil) { + for subscription in moduleDiagnosticSubscriptions { + if let sourceModule, subscription.moduleID == sourceModule.id { + continue + } + if shouldLogDiagnostic(currentLevel: subscription.logLevel, diagnosticLevel: diagnostic.level) { + subscription.handler(diagnostic) + } + } + + if shouldLogDiagnostic(currentLevel: logLevel, diagnosticLevel: diagnostic.level) { + if let onDiagnostic { + onDiagnostic(diagnostic) + } else { + var details = diagnostic.details.mapValues { String(describing: $0.rawValue) } + details["code"] = diagnostic.code + if let module = diagnostic.module { details["module"] = module } + if let moduleName = diagnostic.moduleName { details["moduleName"] = moduleName } + if let originalError = diagnostic.originalError { details["originalError"] = originalError } + + switch diagnostic.level { + case .debug: logger.debug(diagnostic.message, details: details) + case .info: logger.info(diagnostic.message, details: details) + case .warn: logger.warn(diagnostic.message, details: details) + case .error: logger.error(diagnostic.message, details: details) + case .fatal: logger.fatal(diagnostic.message, details: details) + } + } + } + + if diagnostic.level == .error { + emitter.trigger(.error, payload: EventPayload([ + "code": .string(diagnostic.code), + "message": .string(diagnostic.message), + "level": .string("error"), + ])) + } + } + + private func getModuleApi(_ module: FeaturevisorModule) -> FeaturevisorModuleApi { + FeaturevisorModuleApi( + getRevision: { [weak self] in + self?.getRevision() ?? "unknown" + }, + onDiagnostic: { [weak self] handler, options in + guard let self else { return {} } + let subscription = ModuleDiagnosticSubscription( + id: UUID(), + moduleID: module.id, + handler: handler, + logLevel: options.logLevel + ) + self.moduleDiagnosticSubscriptions.append(subscription) + + return { [weak self] in + self?.moduleDiagnosticSubscriptions.removeAll(where: { $0.id == subscription.id }) + } + }, + reportDiagnostic: { [weak self] diagnostic in + var moduleDiagnostic = diagnostic + if let name = module.name { + moduleDiagnostic.module = name + } + self?.reportDiagnostic(moduleDiagnostic, sourceModule: module) + } + ) + } + + private func clearModuleDiagnosticSubscriptions(_ module: FeaturevisorModule) { + moduleDiagnosticSubscriptions.removeAll(where: { $0.moduleID == module.id }) + } + public func setContext(_ context: Context, replace: Bool = false) { + guard !closed else { return } + if replace { self.context = context } else { @@ -116,7 +262,7 @@ public final class FeaturevisorInstance: @unchecked Sendable { EvaluateDependencies( context: getContext(context), logger: logger, - hooksManager: hooksManager, + modulesManager: modulesManager, datafileReader: datafileReader, sticky: options.sticky == nil ? sticky : (sticky ?? [:]).merging(options.sticky ?? [:], uniquingKeysWith: { _, new in new }), defaultVariationValue: options.defaultVariationValue, @@ -125,7 +271,7 @@ public final class FeaturevisorInstance: @unchecked Sendable { } public func evaluateFlag(_ featureKey: FeatureKey, context: Context = [:], options: OverrideOptions = OverrideOptions()) -> Evaluation { - evaluateWithHooks(EvaluateOptions(type: .flag, featureKey: featureKey, dependencies: dependencies(context, options: options))) + evaluateWithModules(EvaluateOptions(type: .flag, featureKey: featureKey, dependencies: dependencies(context, options: options))) } public func isEnabled(_ featureKey: FeatureKey, _ context: Context = [:], _ options: OverrideOptions = OverrideOptions()) -> Bool { @@ -133,7 +279,7 @@ public final class FeaturevisorInstance: @unchecked Sendable { } public func evaluateVariation(_ featureKey: FeatureKey, context: Context = [:], options: OverrideOptions = OverrideOptions()) -> Evaluation { - evaluateWithHooks(EvaluateOptions(type: .variation, featureKey: featureKey, dependencies: dependencies(context, options: options))) + evaluateWithModules(EvaluateOptions(type: .variation, featureKey: featureKey, dependencies: dependencies(context, options: options))) } public func getVariation(_ featureKey: FeatureKey, _ context: Context = [:], _ options: OverrideOptions = OverrideOptions()) -> VariationValue? { @@ -141,7 +287,7 @@ public final class FeaturevisorInstance: @unchecked Sendable { } public func evaluateVariable(_ featureKey: FeatureKey, _ variableKey: VariableKey, context: Context = [:], options: OverrideOptions = OverrideOptions()) -> Evaluation { - evaluateWithHooks(EvaluateOptions(type: .variable, featureKey: featureKey, variableKey: variableKey, dependencies: dependencies(context, options: options))) + evaluateWithModules(EvaluateOptions(type: .variable, featureKey: featureKey, variableKey: variableKey, dependencies: dependencies(context, options: options))) } public func getVariable(_ featureKey: FeatureKey, _ variableKey: VariableKey, _ context: Context = [:], _ options: OverrideOptions = OverrideOptions()) -> VariableValue? { @@ -201,6 +347,16 @@ public final class FeaturevisorInstance: @unchecked Sendable { } } +private func mergeStoredDatafile(existing: DatafileContent, incoming: DatafileContent) -> DatafileContent { + DatafileContent( + schemaVersion: incoming.schemaVersion, + revision: incoming.revision, + featurevisorVersion: incoming.featurevisorVersion, + segments: existing.segments.merging(incoming.segments, uniquingKeysWith: { _, new in new }), + features: existing.features.merging(incoming.features, uniquingKeysWith: { _, new in new }) + ) +} + public func createInstance(_ options: InstanceOptions = InstanceOptions()) -> FeaturevisorInstance { FeaturevisorInstance(options: options) } diff --git a/Sources/Featurevisor/Models.swift b/Sources/Featurevisor/Models.swift index 05cb8f0..d35e7d3 100644 --- a/Sources/Featurevisor/Models.swift +++ b/Sources/Featurevisor/Models.swift @@ -13,17 +13,20 @@ public typealias EvaluatedFeatures = [FeatureKey: EvaluatedFeature] public struct DatafileContent: Codable, Equatable, Sendable { public var schemaVersion: String public var revision: String + public var featurevisorVersion: String? public var segments: [SegmentKey: Segment] public var features: [FeatureKey: Feature] public init( schemaVersion: String, revision: String, + featurevisorVersion: String? = nil, segments: [SegmentKey: Segment], features: [FeatureKey: Feature] ) { self.schemaVersion = schemaVersion self.revision = revision + self.featurevisorVersion = featurevisorVersion self.segments = segments self.features = features } diff --git a/Sources/Featurevisor/Modules.swift b/Sources/Featurevisor/Modules.swift new file mode 100644 index 0000000..ab876bc --- /dev/null +++ b/Sources/Featurevisor/Modules.swift @@ -0,0 +1,152 @@ +import Foundation + +public struct ConfigureBucketKeyOptions: Sendable { + public var featureKey: FeatureKey + public var context: Context + public var bucketBy: BucketBy + public var bucketKey: String + + public init(featureKey: FeatureKey, context: Context, bucketBy: BucketBy, bucketKey: String) { + self.featureKey = featureKey + self.context = context + self.bucketBy = bucketBy + self.bucketKey = bucketKey + } +} + +public struct ConfigureBucketValueOptions: Sendable { + public var featureKey: FeatureKey + public var bucketKey: String + public var context: Context + public var bucketValue: Int + + public init(featureKey: FeatureKey, bucketKey: String, context: Context, bucketValue: Int) { + self.featureKey = featureKey + self.bucketKey = bucketKey + self.context = context + self.bucketValue = bucketValue + } +} + +public struct FeaturevisorModuleApi: Sendable { + public var getRevision: @Sendable () -> String + public var reportDiagnostic: @Sendable (FeaturevisorModuleReportedDiagnostic) -> Void + private var onDiagnosticHandler: FeaturevisorModuleOnDiagnostic + + public init( + getRevision: @escaping @Sendable () -> String, + onDiagnostic: @escaping FeaturevisorModuleOnDiagnostic, + reportDiagnostic: @escaping @Sendable (FeaturevisorModuleReportedDiagnostic) -> Void + ) { + self.getRevision = getRevision + self.reportDiagnostic = reportDiagnostic + self.onDiagnosticHandler = onDiagnostic + } + + @discardableResult + public func onDiagnostic( + _ handler: @escaping FeaturevisorDiagnosticHandler, + options: FeaturevisorModuleDiagnosticOptions = FeaturevisorModuleDiagnosticOptions() + ) -> FeaturevisorUnsubscribe { + onDiagnosticHandler(handler, options) + } +} + +public struct FeaturevisorModule: Sendable { + let id: UUID + + public var name: String? + public var setup: (@Sendable (FeaturevisorModuleApi) -> Void)? + public var before: (@Sendable (EvaluateOptions) -> EvaluateOptions)? + public var bucketKey: (@Sendable (ConfigureBucketKeyOptions) -> String)? + public var bucketValue: (@Sendable (ConfigureBucketValueOptions) -> Int)? + public var after: (@Sendable (Evaluation, EvaluateOptions) -> Evaluation)? + public var close: (@Sendable () -> Void)? + + public init( + name: String? = nil, + setup: (@Sendable (FeaturevisorModuleApi) -> Void)? = nil, + before: (@Sendable (EvaluateOptions) -> EvaluateOptions)? = nil, + bucketKey: (@Sendable (ConfigureBucketKeyOptions) -> String)? = nil, + bucketValue: (@Sendable (ConfigureBucketValueOptions) -> Int)? = nil, + after: (@Sendable (Evaluation, EvaluateOptions) -> Evaluation)? = nil, + close: (@Sendable () -> Void)? = nil + ) { + self.id = UUID() + self.name = name + self.setup = setup + self.before = before + self.bucketKey = bucketKey + self.bucketValue = bucketValue + self.after = after + self.close = close + } +} + +public final class ModulesManager: @unchecked Sendable { + private var modules: [FeaturevisorModule] = [] + private let reportDiagnostic: @Sendable (FeaturevisorDiagnostic, FeaturevisorModule?) -> Void + private let getModuleApi: @Sendable (FeaturevisorModule) -> FeaturevisorModuleApi + private let clearModuleDiagnosticSubscriptions: @Sendable (FeaturevisorModule) -> Void + + public init( + modules: [FeaturevisorModule], + reportDiagnostic: @escaping @Sendable (FeaturevisorDiagnostic, FeaturevisorModule?) -> Void, + getModuleApi: @escaping @Sendable (FeaturevisorModule) -> FeaturevisorModuleApi, + clearModuleDiagnosticSubscriptions: @escaping @Sendable (FeaturevisorModule) -> Void + ) { + self.reportDiagnostic = reportDiagnostic + self.getModuleApi = getModuleApi + self.clearModuleDiagnosticSubscriptions = clearModuleDiagnosticSubscriptions + + for module in modules { + _ = add(module) + } + } + + @discardableResult + public func add(_ module: FeaturevisorModule) -> FeaturevisorUnsubscribe? { + if let name = module.name, modules.contains(where: { $0.name == name }) { + reportDiagnostic( + FeaturevisorDiagnostic( + level: .error, + code: "duplicate_module", + message: "Duplicate module name", + moduleName: name + ), + nil + ) + return nil + } + + module.setup?(getModuleApi(module)) + modules.append(module) + + return { [weak self] in + self?.modules.removeAll(where: { $0.id == module.id }) + self?.clearModuleDiagnosticSubscriptions(module) + } + } + + public func remove(_ name: String) { + let removed = modules.filter { $0.name == name } + modules.removeAll(where: { $0.name == name }) + for module in removed { + clearModuleDiagnosticSubscriptions(module) + } + } + + public func getAll() -> [FeaturevisorModule] { + modules + } + + public func closeAll() { + let existing = modules + modules = [] + + for module in existing { + clearModuleDiagnosticSubscriptions(module) + module.close?() + } + } +} diff --git a/Sources/FeaturevisorCLI/CLI.swift b/Sources/FeaturevisorCLI/CLI.swift index aed4fc6..7c226d2 100644 --- a/Sources/FeaturevisorCLI/CLI.swift +++ b/Sources/FeaturevisorCLI/CLI.swift @@ -12,7 +12,7 @@ struct CLI { case "assess-distribution": return AssessDistributionCommand().run(options) default: - print("Learn more at https://featurevisor.com/docs/sdks/go/") + print("Learn more at https://featurevisor.com/docs/sdks/swift/") return 0 } } diff --git a/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift b/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift index e215301..2b45112 100644 --- a/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift @@ -31,7 +31,6 @@ struct AssessDistributionCommand { guard let datafile = CLIHelpers.buildDatafileJSON( projectDirectoryPath: options.projectDirectoryPath, environment: options.environment, - schemaVersion: options.schemaVersion, inflate: options.inflate ) else { return 1 diff --git a/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift b/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift index 087a8e7..40d3659 100644 --- a/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift @@ -45,7 +45,6 @@ struct BenchmarkCommand { guard let datafile = CLIHelpers.buildDatafileJSON( projectDirectoryPath: options.projectDirectoryPath, environment: options.environment, - schemaVersion: options.schemaVersion, inflate: options.inflate ) else { return 1 diff --git a/Sources/FeaturevisorCLI/Commands/TestCommand.swift b/Sources/FeaturevisorCLI/Commands/TestCommand.swift index 3ac4fb9..c1e3781 100644 --- a/Sources/FeaturevisorCLI/Commands/TestCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/TestCommand.swift @@ -20,11 +20,8 @@ struct TestCommand { return 1 } - let schemaVersion = options.schemaVersion.isEmpty ? (config["schemaVersion"] as? String ?? "") : options.schemaVersion - let segments = loadSegments(options) - let scopesByName = getScopesByName(config) - let datafileCache = buildDatafileCache(options: options, config: config, schemaVersion: schemaVersion) + let datafileCache = buildDatafileCache(options: options, config: config) var testArgs = ["list", "--tests", "--applyMatrix", "--json"] if !options.keyPattern.isEmpty { testArgs.append("--keyPattern=\(options.keyPattern)") } @@ -42,7 +39,6 @@ struct TestCommand { featureKey: featureKey, test: test, options: options, - scopesByName: scopesByName, datafileCache: datafileCache ) summary.passedTests += result.ok ? 1 : 0 @@ -87,100 +83,48 @@ struct TestCommand { return output } - private func getScopesByName(_ config: [String: Any]) -> [String: [String: Any]] { - var result: [String: [String: Any]] = [:] - let scopes = config["scopes"] as? [[String: Any]] ?? [] - - for scope in scopes { - guard let name = scope["name"] as? String else { continue } - result[name] = scope["context"] as? [String: Any] ?? [:] - } - - return result - } - - private func datafileCacheKey(_ environment: String?) -> String { + func datafileCacheKey(_ environment: String?) -> String { if let environment, !environment.isEmpty { return environment } return CLIHelpers.noEnvironmentKey } - private func taggedDatafileCacheKey(_ environment: String?, _ tag: String) -> String { - if let environment, !environment.isEmpty { return "\(environment)-tag-\(tag)" } - return "tag-\(tag)" - } - - private func scopedDatafileCacheKey(_ environment: String?, _ scope: String) -> String { - if let environment, !environment.isEmpty { return "\(environment)-scope-\(scope)" } - return "scope-\(scope)" - } - - private func getDatafilesDirectoryPath(config: [String: Any], options: CLIOptions) -> String { - let configured = (config["datafilesDirectoryPath"] as? String) ?? "datafiles" - if configured.hasPrefix("/") { return configured } - return URL(fileURLWithPath: options.projectDirectoryPath).appendingPathComponent(configured).path - } - - private func ensureDatafilesBuilt(options: CLIOptions, environment: String?, schemaVersion: String) { - var args = ["build", "--no-state-files"] + func targetDatafileCacheKey(_ environment: String?, _ target: String) -> String { if let environment, !environment.isEmpty { - args.append("--environment=\(environment)") - } - if !schemaVersion.isEmpty { - args.append("--schema-version=\(schemaVersion)") + return "\(environment)-target-\(target)" } - if options.inflate > 0 { - args.append("--inflate=\(options.inflate)") + return "false-target-\(target)" + } + + private func loadTargetKeys(_ options: CLIOptions) -> [String] { + guard let targets = CLIHelpers.runJSON(projectDirectoryPath: options.projectDirectoryPath, args: ["list", "--targets", "--json"]) as? [[String: Any]] else { + return [] } - _ = FeaturevisorProcess.run(projectDirectoryPath: options.projectDirectoryPath, args: args) + return targets.compactMap { $0["key"] as? String } } - private func buildDatafileCache(options: CLIOptions, config: [String: Any], schemaVersion: String) -> [String: DatafileContent] { + private func buildDatafileCache(options: CLIOptions, config: [String: Any]) -> [String: DatafileContent] { var cache: [String: DatafileContent] = [:] let envs = CLIHelpers.stringArray(config["environments"]) let environments: [String?] = envs.isEmpty ? [nil] : envs.map(Optional.some) + let targetKeys = loadTargetKeys(options) for environment in environments { guard let base = CLIHelpers.buildDatafileJSON( projectDirectoryPath: options.projectDirectoryPath, environment: environment, - schemaVersion: schemaVersion, - inflate: options.inflate, - tag: nil + inflate: options.inflate ) else { continue } cache[datafileCacheKey(environment)] = base - if options.withTags { - for tag in CLIHelpers.stringArray(config["tags"]) { - if let tagged = CLIHelpers.buildDatafileJSON( - projectDirectoryPath: options.projectDirectoryPath, - environment: environment, - schemaVersion: schemaVersion, - inflate: options.inflate, - tag: tag - ) { - cache[taggedDatafileCacheKey(environment, tag)] = tagged - } - } - } - - if options.withScopes { - ensureDatafilesBuilt(options: options, environment: environment, schemaVersion: schemaVersion) - let dir = getDatafilesDirectoryPath(config: config, options: options) - let scopes = config["scopes"] as? [[String: Any]] ?? [] - for scope in scopes { - guard let scopeName = scope["name"] as? String else { continue } - let filename = "featurevisor-scope-\(scopeName).json" - let path: String - if let environment, !environment.isEmpty { - path = URL(fileURLWithPath: dir).appendingPathComponent(environment).appendingPathComponent(filename).path - } else { - path = URL(fileURLWithPath: dir).appendingPathComponent(filename).path - } - - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let datafile = try? DatafileContent.fromData(data) else { continue } - cache[scopedDatafileCacheKey(environment, scopeName)] = datafile + for target in targetKeys { + if let targetDatafile = CLIHelpers.buildDatafileJSON( + projectDirectoryPath: options.projectDirectoryPath, + environment: environment, + inflate: options.inflate, + target: target + ) { + cache[targetDatafileCacheKey(environment, target)] = targetDatafile } } } @@ -191,11 +135,11 @@ struct TestCommand { private func sdkForAssertion(datafile: DatafileContent, assertion: [String: Any], options: CLIOptions) -> FeaturevisorInstance { let sticky = CLIHelpers.anyToSticky(assertion["sticky"]) let forcedAt = CLIHelpers.doubleValue(assertion["at"]) - let hook = Hook(name: "test-hook", bucketValue: { current in + let module = FeaturevisorModule(name: "test-module", bucketValue: { options in if let at = forcedAt { return Int(at * 1000) } - return current + return options.bucketValue }) return createInstance( @@ -203,7 +147,7 @@ struct TestCommand { datafile: datafile, logLevel: CLIHelpers.loggerLevel(options), sticky: sticky, - hooks: [hook] + modules: [module] ) ) } @@ -212,7 +156,6 @@ struct TestCommand { featureKey: String, test: [String: Any], options: CLIOptions, - scopesByName: [String: [String: Any]], datafileCache: [String: DatafileContent] ) -> (ok: Bool, passed: Int, failed: Int) { guard let assertions = test["assertions"] as? [[String: Any]] else { @@ -227,17 +170,9 @@ struct TestCommand { for assertion in assertions { let description = (assertion["description"] as? String) ?? "assertion" let env = assertion["environment"] as? String - let scope = assertion["scope"] as? String - let tag = assertion["tag"] as? String - - let cacheKey: String - if let scope { - cacheKey = scopedDatafileCacheKey(env, scope) - } else if let tag { - cacheKey = taggedDatafileCacheKey(env, tag) - } else { - cacheKey = datafileCacheKey(env) - } + let target = assertion["target"] as? String + + let cacheKey = target.map { targetDatafileCacheKey(env, $0) } ?? datafileCacheKey(env) guard let datafile = datafileCache[cacheKey] ?? datafileCache[datafileCacheKey(env)] else { failed += 1 @@ -254,9 +189,6 @@ struct TestCommand { let sdk = sdkForAssertion(datafile: datafile, assertion: assertion, options: options) var contextMap: [String: Any] = [:] - if let scope, !options.withScopes, let scopedContext = scopesByName[scope] { - contextMap.merge(scopedContext, uniquingKeysWith: { _, new in new }) - } if let assertionContext = assertion["context"] as? [String: Any] { contextMap.merge(assertionContext, uniquingKeysWith: { _, new in new }) } diff --git a/Sources/FeaturevisorCLI/Support/CLIHelpers.swift b/Sources/FeaturevisorCLI/Support/CLIHelpers.swift index 536f91b..ace3aea 100644 --- a/Sources/FeaturevisorCLI/Support/CLIHelpers.swift +++ b/Sources/FeaturevisorCLI/Support/CLIHelpers.swift @@ -58,14 +58,13 @@ enum CLIHelpers { return try? JSONSerialization.jsonObject(with: data) } - static func buildDatafileJSON(projectDirectoryPath: String, environment: String?, schemaVersion: String, inflate: Int, tag: String? = nil) -> DatafileContent? { + static func buildDatafileJSON(projectDirectoryPath: String, environment: String?, inflate: Int, target: String? = nil) -> DatafileContent? { var args = ["build"] if let environment, !environment.isEmpty { args.append("--environment=\(environment)") } - if !schemaVersion.isEmpty { args.append("--schema-version=\(schemaVersion)") } if inflate > 0 { args.append("--inflate=\(inflate)") } - if let tag { args.append("--tag=\(tag)") } + if let target { args.append("--target=\(target)") } args.append("--json") let result = FeaturevisorProcess.run(projectDirectoryPath: projectDirectoryPath, args: args) diff --git a/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift b/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift index 213f9e4..1efe0e8 100644 --- a/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift +++ b/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift @@ -7,6 +7,9 @@ final class FeaturevisorCLITests: XCTestCase { "test", "--keyPattern=foo", "--with-scopes", + "--with-tags", + "--schemaVersion=1", + "--schema-version=2", "--n=10", "--projectDirectoryPath=/tmp/project", ]) @@ -14,10 +17,19 @@ final class FeaturevisorCLITests: XCTestCase { XCTAssertEqual(opts.command, "test") XCTAssertEqual(opts.keyPattern, "foo") XCTAssertTrue(opts.withScopes) + XCTAssertTrue(opts.withTags) + XCTAssertEqual(opts.schemaVersion, "2") XCTAssertEqual(opts.n, 10) XCTAssertEqual(opts.projectDirectoryPath, "/tmp/project") } + func testTargetDatafileCacheKey() { + let command = TestCommand() + + XCTAssertEqual(command.targetDatafileCacheKey(nil, "checkout"), "false-target-checkout") + XCTAssertEqual(command.targetDatafileCacheKey("production", "checkout"), "production-target-checkout") + } + func testDefaultCommandShowsHelp() { let code = CLI().run(args: []) XCTAssertEqual(code, 0) diff --git a/Tests/FeaturevisorTests/EventsTests.swift b/Tests/FeaturevisorTests/EventsTests.swift index 961e9ed..9468263 100644 --- a/Tests/FeaturevisorTests/EventsTests.swift +++ b/Tests/FeaturevisorTests/EventsTests.swift @@ -24,10 +24,11 @@ final class EventsTests: XCTestCase { let p = DatafileReader(datafile: d1, logger: logger) let n = DatafileReader(datafile: d2, logger: logger) - let params = getParamsForDatafileSetEvent(previousDatafileReader: p, newDatafileReader: n) + let params = getParamsForDatafileSetEvent(previousDatafileReader: p, newDatafileReader: n, replace: true) XCTAssertEqual(params["revision"], .string("2")) XCTAssertEqual(params["previousRevision"], .string("1")) XCTAssertEqual(params["revisionChanged"], .bool(true)) XCTAssertEqual(params["features"], .array([.string("test")])) + XCTAssertEqual(params["replaced"], .bool(true)) } } diff --git a/Tests/FeaturevisorTests/InstanceTests.swift b/Tests/FeaturevisorTests/InstanceTests.swift index b123097..6618ec9 100644 --- a/Tests/FeaturevisorTests/InstanceTests.swift +++ b/Tests/FeaturevisorTests/InstanceTests.swift @@ -35,4 +35,55 @@ final class InstanceTests: XCTestCase { XCTAssertTrue(contextEvent.value) XCTAssertTrue(stickyEvent.value) } + + func testSetDatafileMergesByDefaultAndReplacesWhenRequested() { + let sdk = createInstance(InstanceOptions(datafile: TestFixtures.basicDatafile())) + + let secondDatafile = DatafileContent( + schemaVersion: "2", + revision: "2", + featurevisorVersion: "3.0.0", + segments: [:], + features: [ + "second": Feature( + key: "second", + hash: nil, + deprecated: nil, + required: nil, + variablesSchema: nil, + disabledVariationValue: nil, + variations: nil, + bucketBy: .single("userId"), + traffic: [Traffic(key: "all", segments: .all, percentage: 100_000, enabled: true, variation: nil, variables: nil, variationWeights: nil, variableOverrides: nil, allocation: nil)], + force: nil, + ranges: nil + ), + ] + ) + + sdk.setDatafile(secondDatafile) + XCTAssertNotNil(sdk.getFeature("test")) + XCTAssertNotNil(sdk.getFeature("second")) + XCTAssertEqual(sdk.getRevision(), "2") + + sdk.setDatafile(secondDatafile, replace: true) + XCTAssertNil(sdk.getFeature("test")) + XCTAssertNotNil(sdk.getFeature("second")) + } + + func testDatafileSetEventIncludesReplaced() { + let sdk = createInstance(InstanceOptions(datafile: TestFixtures.basicDatafile())) + let replaced = ConcurrencyBox(nil) + + let unsubscribe = sdk.on(.datafileSet) { payload in + replaced.value = payload.params["replaced"] + } + + var next = TestFixtures.basicDatafile() + next.revision = "2" + sdk.setDatafile(next, replace: true) + unsubscribe() + + XCTAssertEqual(replaced.value, .bool(true)) + } } diff --git a/Tests/FeaturevisorTests/ModulesTests.swift b/Tests/FeaturevisorTests/ModulesTests.swift new file mode 100644 index 0000000..8614124 --- /dev/null +++ b/Tests/FeaturevisorTests/ModulesTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import Featurevisor + +final class ModulesTests: XCTestCase { + func testModuleSetupDiagnosticsAddRemoveAndClose() { + let setupCalled = ConcurrencyBox(false) + let closeCalled = ConcurrencyBox(false) + let setupRevision = ConcurrencyBox("") + let moduleSawDatafileSet = ConcurrencyBox(false) + let diagnostics = ConcurrencyBox<[FeaturevisorDiagnostic]>([]) + + let module = FeaturevisorModule( + name: "observer", + setup: { api in + setupCalled.value = true + setupRevision.value = api.getRevision() + api.onDiagnostic { diagnostic in + if diagnostic.code == "datafile_set" { + moduleSawDatafileSet.value = true + } + } + api.reportDiagnostic(FeaturevisorDiagnostic(level: .info, code: "module_ready", message: "Module ready")) + }, + close: { + closeCalled.value = true + } + ) + + let sdk = createInstance(InstanceOptions( + datafile: TestFixtures.basicDatafile(), + logLevel: .info, + onDiagnostic: { diagnostic in + diagnostics.value.append(diagnostic) + }, + modules: [module] + )) + + XCTAssertTrue(setupCalled.value) + XCTAssertEqual(setupRevision.value, "unknown") + XCTAssertTrue(moduleSawDatafileSet.value) + XCTAssertTrue(diagnostics.value.contains { $0.code == "module_ready" && $0.module == "observer" }) + + XCTAssertNil(sdk.addModule(FeaturevisorModule(name: "observer"))) + XCTAssertTrue(diagnostics.value.contains { $0.code == "duplicate_module" && $0.level == .error && $0.moduleName == "observer" }) + + let removable = FeaturevisorModule(name: "removable") + XCTAssertNotNil(sdk.addModule(removable)) + sdk.removeModule("removable") + + sdk.close() + XCTAssertTrue(closeCalled.value) + } + + func testBeforeAfterAndBucketModules() { + let bucketKeySeen = ConcurrencyBox("") + + let module = FeaturevisorModule( + name: "transformer", + before: { options in + var updated = options + updated.dependencies.context["userId"] = .string("from-before") + return updated + }, + bucketKey: { options in + bucketKeySeen.value = options.bucketKey + return options.bucketKey + }, + bucketValue: { _ in + 50_000 + }, + after: { evaluation, _ in + var updated = evaluation + updated.reason = .forced + return updated + } + ) + + let datafile = DatafileContent( + schemaVersion: "2", + revision: "1", + segments: [:], + features: [ + "exp": Feature( + key: "exp", + hash: nil, + deprecated: nil, + required: nil, + variablesSchema: nil, + disabledVariationValue: nil, + variations: nil, + bucketBy: .single("userId"), + traffic: [Traffic(key: "all", segments: .all, percentage: 100_000, enabled: true, variation: nil, variables: nil, variationWeights: nil, variableOverrides: nil, allocation: nil)], + force: nil, + ranges: [[0, 10_000]] + ), + ] + ) + + let sdk = createInstance(InstanceOptions(datafile: datafile, modules: [module])) + let evaluation = sdk.evaluateFlag("exp") + + XCTAssertEqual(bucketKeySeen.value, "from-before.exp") + XCTAssertEqual(evaluation.bucketValue, 50_000) + XCTAssertEqual(evaluation.reason, .forced) + } +} diff --git a/Tests/FeaturevisorTests/ParityBehaviorTests.swift b/Tests/FeaturevisorTests/ParityBehaviorTests.swift index be1b986..f60d169 100644 --- a/Tests/FeaturevisorTests/ParityBehaviorTests.swift +++ b/Tests/FeaturevisorTests/ParityBehaviorTests.swift @@ -128,8 +128,8 @@ final class ParityBehaviorTests: XCTestCase { ] ) - let hook = Hook(name: "force-bucket", bucketValue: { _ in 50_000 }) - let sdk = createInstance(InstanceOptions(datafile: datafile, hooks: [hook])) + let module = FeaturevisorModule(name: "force-bucket", bucketValue: { _ in 50_000 }) + let sdk = createInstance(InstanceOptions(datafile: datafile, modules: [module])) let evaluation = sdk.evaluateFlag("exp", context: ["userId": .string("1")]) XCTAssertEqual(evaluation.reason, .outOfRange) @@ -167,4 +167,3 @@ final class ParityBehaviorTests: XCTestCase { XCTAssertEqual(sdk.getVariable("disabled", "b", ["userId": .string("1")]), .string("B-disabled")) } } - From 11bac5e239acf465485a81e8b329f0e2d1bd4d53 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 25 Jun 2026 21:29:39 +0200 Subject: [PATCH 02/13] updates --- README.md | 52 ++++++++++++++++++- .../Commands/TestCommand.swift | 16 ++++-- .../FeaturevisorCLITests.swift | 48 +++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 583ba30..ab184e9 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) + - [Merging by default](#merging-by-default) + - [Replacing](#replacing) + - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) - [Interval-based update](#interval-based-update) - [Logging](#logging) @@ -34,6 +37,7 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - [`sticky_set`](#sticky_set) + - [`error`](#error) - [Evaluation details](#evaluation-details) - [Modules](#modules) - [Defining a module](#defining-a-module) @@ -304,13 +308,49 @@ You can also set using raw JSON string: f.setDatafile(json: jsonString) ``` -By default, `setDatafile` merges the incoming datafile into the SDK's stored datafile. Pass `replace: true` to replace the stored datafile instead: +### Merging by default + +By default, `setDatafile` merges the incoming datafile with the SDK instance's existing datafile: + +- incoming `features` and `segments` override matching keys +- existing `features` and `segments` that are missing from the incoming datafile are kept +- `revision`, `schemaVersion`, and `featurevisorVersion` are taken from the incoming datafile + +This means you can call `setDatafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. + +### Replacing + +Pass `replace: true` to replace the stored datafile entirely: ```swift f.setDatafile(datafileContent, replace: true) f.setDatafile(json: jsonString, replace: true) ``` +### Loading datafiles on demand + +Because merging is the default, a single SDK instance can start with a small datafile and load more datafiles later as your application needs them, instead of downloading every feature upfront. + +This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: + +```swift +let f = createInstance(InstanceOptions()) + +func loadDatafile(target: String) { + let url = URL(string: "https://cdn.yoursite.com/production/featurevisor-\(target).json")! + if let data = try? Data(contentsOf: url), + let datafile = try? DatafileContent.fromData(data) { + // merges into whatever was loaded before + f.setDatafile(datafile) + } +} + +loadDatafile(target: "products") + +// later, when the user reaches checkout +loadDatafile(target: "checkout") +``` + ### Updating datafile You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile_set) event that you can listen and react to accordingly. @@ -428,6 +468,16 @@ let unsubscribe = f.on(.stickySet) { _ in unsubscribe() ``` +### `error` + +```swift +let unsubscribe = f.on(.error) { payload in + print(payload.params["message"] ?? "") +} + +unsubscribe() +``` + ## Evaluation details If you need evaluation metadata, use: diff --git a/Sources/FeaturevisorCLI/Commands/TestCommand.swift b/Sources/FeaturevisorCLI/Commands/TestCommand.swift index c1e3781..e8a101c 100644 --- a/Sources/FeaturevisorCLI/Commands/TestCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/TestCommand.swift @@ -95,6 +95,18 @@ struct TestCommand { return "false-target-\(target)" } + func datafileCacheKeyForAssertion(_ assertion: [String: Any], datafileCache: [String: DatafileContent]) -> String { + let env = assertion["environment"] as? String + let target = assertion["target"] as? String + if let target { + let targetKey = targetDatafileCacheKey(env, target) + if datafileCache[targetKey] != nil { + return targetKey + } + } + return datafileCacheKey(env) + } + private func loadTargetKeys(_ options: CLIOptions) -> [String] { guard let targets = CLIHelpers.runJSON(projectDirectoryPath: options.projectDirectoryPath, args: ["list", "--targets", "--json"]) as? [[String: Any]] else { return [] @@ -170,9 +182,7 @@ struct TestCommand { for assertion in assertions { let description = (assertion["description"] as? String) ?? "assertion" let env = assertion["environment"] as? String - let target = assertion["target"] as? String - - let cacheKey = target.map { targetDatafileCacheKey(env, $0) } ?? datafileCacheKey(env) + let cacheKey = datafileCacheKeyForAssertion(assertion, datafileCache: datafileCache) guard let datafile = datafileCache[cacheKey] ?? datafileCache[datafileCacheKey(env)] else { failed += 1 diff --git a/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift b/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift index 1efe0e8..10afc96 100644 --- a/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift +++ b/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift @@ -1,4 +1,5 @@ import XCTest +import Featurevisor @testable import FeaturevisorCLI final class FeaturevisorCLITests: XCTestCase { @@ -30,6 +31,53 @@ final class FeaturevisorCLITests: XCTestCase { XCTAssertEqual(command.targetDatafileCacheKey("production", "checkout"), "production-target-checkout") } + func testTargetAssertionSelectsTargetDatafile() { + let command = TestCommand() + let cache = [ + "production": DatafileContent(schemaVersion: "2", revision: "base", segments: [:], features: [:]), + "production-target-checkout": DatafileContent(schemaVersion: "2", revision: "target", segments: [:], features: [:]), + ] + + XCTAssertEqual( + command.datafileCacheKeyForAssertion( + ["environment": "production", "target": "checkout"], + datafileCache: cache + ), + "production-target-checkout" + ) + } + + func testTargetAssertionFallsBackToBaseDatafile() { + let command = TestCommand() + let cache = [ + "production": DatafileContent(schemaVersion: "2", revision: "base", segments: [:], features: [:]), + ] + + XCTAssertEqual( + command.datafileCacheKeyForAssertion( + ["environment": "production", "target": "checkout"], + datafileCache: cache + ), + "production" + ) + } + + func testNoEnvironmentTargetAssertionSelectsTargetDatafile() { + let command = TestCommand() + let cache = [ + CLIHelpers.noEnvironmentKey: DatafileContent(schemaVersion: "2", revision: "base", segments: [:], features: [:]), + "false-target-checkout": DatafileContent(schemaVersion: "2", revision: "target", segments: [:], features: [:]), + ] + + XCTAssertEqual( + command.datafileCacheKeyForAssertion( + ["target": "checkout"], + datafileCache: cache + ), + "false-target-checkout" + ) + } + func testDefaultCommandShowsHelp() { let code = CLI().run(args: []) XCTAssertEqual(code, 0) From bfc516d7bbec06679d777ac501ddb6235093cc7b Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 26 Jun 2026 19:06:22 +0200 Subject: [PATCH 03/13] updates --- Sources/Featurevisor/Emitter.swift | 18 ++-- Sources/Featurevisor/Modules.swift | 33 ++++++- Tests/FeaturevisorTests/ConditionsTests.swift | 17 ++++ .../DatafileReaderTests.swift | 38 ++++++++ Tests/FeaturevisorTests/EmitterTests.swift | 19 ++++ Tests/FeaturevisorTests/ModulesTests.swift | 97 +++++++++++++++++++ 6 files changed, 210 insertions(+), 12 deletions(-) diff --git a/Sources/Featurevisor/Emitter.swift b/Sources/Featurevisor/Emitter.swift index 16698ea..9443fba 100644 --- a/Sources/Featurevisor/Emitter.swift +++ b/Sources/Featurevisor/Emitter.swift @@ -15,24 +15,28 @@ public struct EventPayload: Sendable { public typealias EventCallback = @Sendable (_ payload: EventPayload) -> Void public final class Emitter: @unchecked Sendable { - private var listeners: [EventName: [UUID: EventCallback]] = [:] + private struct Listener { + let id: UUID + let callback: EventCallback + } + + private var listeners: [EventName: [Listener]] = [:] public init() {} @discardableResult public func on(_ eventName: EventName, callback: @escaping EventCallback) -> () -> Void { let id = UUID() - listeners[eventName, default: [:]][id] = callback + listeners[eventName, default: []].append(Listener(id: id, callback: callback)) return { [weak self] in - self?.listeners[eventName]?[id] = nil + self?.listeners[eventName]?.removeAll(where: { $0.id == id }) } } public func trigger(_ eventName: EventName, payload: EventPayload = EventPayload()) { - if let callbacks = listeners[eventName]?.values { - for callback in callbacks { - callback(payload) - } + let callbacks = listeners[eventName]?.map { $0.callback } ?? [] + for callback in callbacks { + callback(payload) } } diff --git a/Sources/Featurevisor/Modules.swift b/Sources/Featurevisor/Modules.swift index ab876bc..ee4eac4 100644 --- a/Sources/Featurevisor/Modules.swift +++ b/Sources/Featurevisor/Modules.swift @@ -61,7 +61,7 @@ public struct FeaturevisorModule: Sendable { public var bucketKey: (@Sendable (ConfigureBucketKeyOptions) -> String)? public var bucketValue: (@Sendable (ConfigureBucketValueOptions) -> Int)? public var after: (@Sendable (Evaluation, EvaluateOptions) -> Evaluation)? - public var close: (@Sendable () -> Void)? + public var close: (@Sendable () throws -> Void)? public init( name: String? = nil, @@ -70,7 +70,7 @@ public struct FeaturevisorModule: Sendable { bucketKey: (@Sendable (ConfigureBucketKeyOptions) -> String)? = nil, bucketValue: (@Sendable (ConfigureBucketValueOptions) -> Int)? = nil, after: (@Sendable (Evaluation, EvaluateOptions) -> Evaluation)? = nil, - close: (@Sendable () -> Void)? = nil + close: (@Sendable () throws -> Void)? = nil ) { self.id = UUID() self.name = name @@ -123,8 +123,13 @@ public final class ModulesManager: @unchecked Sendable { modules.append(module) return { [weak self] in - self?.modules.removeAll(where: { $0.id == module.id }) - self?.clearModuleDiagnosticSubscriptions(module) + guard let self else { return } + let existed = self.modules.contains(where: { $0.id == module.id }) + self.modules.removeAll(where: { $0.id == module.id }) + self.clearModuleDiagnosticSubscriptions(module) + if existed { + self.closeModule(module) + } } } @@ -133,6 +138,7 @@ public final class ModulesManager: @unchecked Sendable { modules.removeAll(where: { $0.name == name }) for module in removed { clearModuleDiagnosticSubscriptions(module) + closeModule(module) } } @@ -146,7 +152,24 @@ public final class ModulesManager: @unchecked Sendable { for module in existing { clearModuleDiagnosticSubscriptions(module) - module.close?() + closeModule(module) + } + } + + private func closeModule(_ module: FeaturevisorModule) { + do { + try module.close?() + } catch { + reportDiagnostic( + FeaturevisorDiagnostic( + level: .error, + code: "module_close_error", + message: "Module close failed", + moduleName: module.name, + originalError: String(describing: error) + ), + nil + ) } } } diff --git a/Tests/FeaturevisorTests/ConditionsTests.swift b/Tests/FeaturevisorTests/ConditionsTests.swift index f0b1d96..0b767f4 100644 --- a/Tests/FeaturevisorTests/ConditionsTests.swift +++ b/Tests/FeaturevisorTests/ConditionsTests.swift @@ -31,4 +31,21 @@ final class ConditionsTests: XCTestCase { let after = Condition.predicate(.init(attribute: "date", operator: "after", value: .string("2024-01-01T00:00:00Z"))) XCTAssertTrue(allConditionsMatched(after, context: ["date": .string("2024-01-02T00:00:00Z")])) } + + func testNotNegatesImplicitAnd() { + let countryUS = Condition.predicate(.init(attribute: "country", operator: "equals", value: .string("us"))) + let mobile = Condition.predicate(.init(attribute: "device", operator: "equals", value: .string("mobile"))) + + XCTAssertFalse(allConditionsMatched(.not([countryUS, mobile]), context: ["country": .string("us"), "device": .string("mobile")])) + XCTAssertTrue(allConditionsMatched(.not([countryUS, mobile]), context: ["country": .string("us"), "device": .string("desktop")])) + XCTAssertFalse(allConditionsMatched(.not([]), context: [:])) + } + + func testNotWithNestedOrMeansNoneMatch() { + let countryUS = Condition.predicate(.init(attribute: "country", operator: "equals", value: .string("us"))) + let countryNL = Condition.predicate(.init(attribute: "country", operator: "equals", value: .string("nl"))) + + XCTAssertFalse(allConditionsMatched(.not([.or([countryUS, countryNL])]), context: ["country": .string("us")])) + XCTAssertTrue(allConditionsMatched(.not([.or([countryUS, countryNL])]), context: ["country": .string("de")])) + } } diff --git a/Tests/FeaturevisorTests/DatafileReaderTests.swift b/Tests/FeaturevisorTests/DatafileReaderTests.swift index c00b4fc..38bf855 100644 --- a/Tests/FeaturevisorTests/DatafileReaderTests.swift +++ b/Tests/FeaturevisorTests/DatafileReaderTests.swift @@ -24,4 +24,42 @@ final class DatafileReaderTests: XCTestCase { XCTAssertEqual(force.force?.variation, "control") XCTAssertEqual(force.index, 0) } + + func testNotSegmentsNegateImplicitAnd() { + let logger = createLogger(level: .fatal) + let datafile = DatafileContent( + schemaVersion: "2", + revision: "1", + segments: [ + "mobile": Segment(key: "mobile", conditions: .tree(.predicate(.init(attribute: "device", operator: "equals", value: .string("mobile"))))), + "desktop": Segment(key: "desktop", conditions: .tree(.predicate(.init(attribute: "device", operator: "equals", value: .string("desktop"))))), + "nl": Segment(key: "nl", conditions: .tree(.predicate(.init(attribute: "country", operator: "equals", value: .string("nl"))))), + ], + features: [:] + ) + let reader = DatafileReader(datafile: datafile, logger: logger) + + let notAll = GroupSegment.not([.key("mobile"), .key("nl")]) + XCTAssertFalse(reader.allSegmentsAreMatched(notAll, context: ["device": .string("mobile"), "country": .string("nl")])) + XCTAssertTrue(reader.allSegmentsAreMatched(notAll, context: ["device": .string("desktop"), "country": .string("nl")])) + XCTAssertFalse(reader.allSegmentsAreMatched(.not([]), context: [:])) + } + + func testNotSegmentsWithNestedOrMeanNoneMatch() { + let logger = createLogger(level: .fatal) + let datafile = DatafileContent( + schemaVersion: "2", + revision: "1", + segments: [ + "mobile": Segment(key: "mobile", conditions: .tree(.predicate(.init(attribute: "device", operator: "equals", value: .string("mobile"))))), + "desktop": Segment(key: "desktop", conditions: .tree(.predicate(.init(attribute: "device", operator: "equals", value: .string("desktop"))))), + ], + features: [:] + ) + let reader = DatafileReader(datafile: datafile, logger: logger) + + let noneOfDevices = GroupSegment.not([.or([.key("mobile"), .key("desktop")])]) + XCTAssertFalse(reader.allSegmentsAreMatched(noneOfDevices, context: ["device": .string("mobile")])) + XCTAssertTrue(reader.allSegmentsAreMatched(noneOfDevices, context: ["device": .string("tv")])) + } } diff --git a/Tests/FeaturevisorTests/EmitterTests.swift b/Tests/FeaturevisorTests/EmitterTests.swift index e11c3ba..50137ed 100644 --- a/Tests/FeaturevisorTests/EmitterTests.swift +++ b/Tests/FeaturevisorTests/EmitterTests.swift @@ -13,4 +13,23 @@ final class EmitterTests: XCTestCase { XCTAssertEqual(count.value, 1) } + + func testTriggerUsesListenerSnapshot() { + let emitter = Emitter() + let calls = ConcurrencyBox<[String]>([]) + let unsubscribeSecond = ConcurrencyBox<(() -> Void)?>(nil) + + _ = emitter.on(.stickySet) { _ in + calls.value.append("first") + unsubscribeSecond.value?() + } + unsubscribeSecond.value = emitter.on(.stickySet) { _ in + calls.value.append("second") + } + + emitter.trigger(.stickySet) + emitter.trigger(.stickySet) + + XCTAssertEqual(calls.value, ["first", "second", "first"]) + } } diff --git a/Tests/FeaturevisorTests/ModulesTests.swift b/Tests/FeaturevisorTests/ModulesTests.swift index 8614124..d4b45fb 100644 --- a/Tests/FeaturevisorTests/ModulesTests.swift +++ b/Tests/FeaturevisorTests/ModulesTests.swift @@ -2,6 +2,10 @@ import XCTest @testable import Featurevisor final class ModulesTests: XCTestCase { + enum TestModuleError: Error { + case closeFailed + } + func testModuleSetupDiagnosticsAddRemoveAndClose() { let setupCalled = ConcurrencyBox(false) let closeCalled = ConcurrencyBox(false) @@ -51,6 +55,99 @@ final class ModulesTests: XCTestCase { XCTAssertTrue(closeCalled.value) } + func testRemovedModulesAreClosed() { + let closed = ConcurrencyBox<[String]>([]) + let sdk = createInstance(InstanceOptions(logLevel: .fatal)) + + let unsubscribe = sdk.addModule(FeaturevisorModule( + name: "dynamic", + close: { + closed.value.append("dynamic") + } + )) + + XCTAssertNotNil(unsubscribe) + unsubscribe?() + unsubscribe?() + + _ = sdk.addModule(FeaturevisorModule( + name: "dynamic", + close: { + closed.value.append("dynamic-again") + } + )) + sdk.removeModule("dynamic") + + XCTAssertEqual(closed.value, ["dynamic", "dynamic-again"]) + } + + func testModuleCloseErrorsAreReportedAndDoNotStopCleanup() { + let diagnostics = ConcurrencyBox<[FeaturevisorDiagnostic]>([]) + let errorEventCodes = ConcurrencyBox<[String]>([]) + let closed = ConcurrencyBox<[String]>([]) + + let sdk = createInstance(InstanceOptions( + logLevel: .info, + onDiagnostic: { diagnostic in + diagnostics.value.append(diagnostic) + } + )) + + sdk.on(.error) { payload in + if case .string(let code)? = payload.params["code"] { + errorEventCodes.value.append(code) + } + } + + _ = sdk.addModule(FeaturevisorModule( + name: "first", + close: { + closed.value.append("first") + throw TestModuleError.closeFailed + } + )) + _ = sdk.addModule(FeaturevisorModule( + name: "second", + close: { + closed.value.append("second") + } + )) + + sdk.close() + + XCTAssertEqual(closed.value, ["first", "second"]) + XCTAssertTrue(diagnostics.value.contains { + $0.code == "module_close_error" && + $0.level == .error && + $0.moduleName == "first" && + $0.originalError?.contains("closeFailed") == true + }) + XCTAssertTrue(errorEventCodes.value.contains("module_close_error")) + } + + func testModuleUnsubscribeReportsCloseErrors() { + let diagnostics = ConcurrencyBox<[FeaturevisorDiagnostic]>([]) + let sdk = createInstance(InstanceOptions( + logLevel: .info, + onDiagnostic: { diagnostic in + diagnostics.value.append(diagnostic) + } + )) + + let unsubscribe = sdk.addModule(FeaturevisorModule( + name: "dynamic", + close: { + throw TestModuleError.closeFailed + } + )) + + XCTAssertNotNil(unsubscribe) + unsubscribe?() + unsubscribe?() + + XCTAssertEqual(diagnostics.value.filter { $0.code == "module_close_error" && $0.moduleName == "dynamic" }.count, 1) + } + func testBeforeAfterAndBucketModules() { let bucketKeySeen = ConcurrencyBox("") From 32a540a46204fcb4ad1d53d3101d29e98f888339 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 27 Jun 2026 23:27:30 +0200 Subject: [PATCH 04/13] updates --- README.md | 16 +++++----- Sources/Featurevisor/DatafileReader.swift | 30 +++++++++---------- Sources/Featurevisor/Evaluate.swift | 10 +++---- Sources/Featurevisor/Events.swift | 2 +- Sources/Featurevisor/Instance.swift | 6 ++-- .../Commands/AssessDistributionCommand.swift | 2 +- .../Commands/BenchmarkCommand.swift | 2 +- .../Commands/TestCommand.swift | 2 +- Tests/FeaturevisorTests/ChildTests.swift | 4 +-- .../FeaturevisorTests/FeaturevisorTests.swift | 4 +-- Tests/FeaturevisorTests/InstanceTests.swift | 8 ++--- Tests/FeaturevisorTests/ModulesTests.swift | 10 +++---- .../ParityBehaviorTests.swift | 8 ++--- 13 files changed, 52 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index ab184e9..53967fb 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ let data = try Data(contentsOf: datafileURL) let datafileContent = try DatafileContent.fromData(data) let f = createInstance( - InstanceOptions( + FeaturevisorOptions( datafile: datafileContent ) ) @@ -118,7 +118,7 @@ You can set context at the time of initialization: ```swift let f = createInstance( - InstanceOptions( + FeaturevisorOptions( context: [ "deviceId": .string("123"), "country": .string("nl"), @@ -268,7 +268,7 @@ For the lifecycle of the SDK instance in your application, you can set some feat ```swift let f = createInstance( - InstanceOptions( + FeaturevisorOptions( sticky: [ "myFeatureKey": EvaluatedFeature( enabled: true, @@ -334,7 +334,7 @@ Because merging is the default, a single SDK instance can start with a small dat This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: ```swift -let f = createInstance(InstanceOptions()) +let f = createInstance(FeaturevisorOptions()) func loadDatafile(target: String) { let url = URL(string: "https://cdn.yoursite.com/production/featurevisor-\(target).json")! @@ -389,7 +389,7 @@ You can set log level at initialization: ```swift let f = createInstance( - InstanceOptions( + FeaturevisorOptions( logLevel: .debug ) ) @@ -411,7 +411,7 @@ let logger = createLogger(level: .debug) { level, message, details in } let f = createInstance( - InstanceOptions( + FeaturevisorOptions( datafile: datafileContent, logger: logger ) @@ -424,7 +424,7 @@ You can observe SDK and module diagnostics with `onDiagnostic`: ```swift let f = createInstance( - InstanceOptions( + FeaturevisorOptions( onDiagnostic: { diagnostic in print(diagnostic.level, diagnostic.code, diagnostic.message) } @@ -530,7 +530,7 @@ let module = FeaturevisorModule( ```swift let f = createInstance( - InstanceOptions( + FeaturevisorOptions( modules: [module] ) ) diff --git a/Sources/Featurevisor/DatafileReader.swift b/Sources/Featurevisor/DatafileReader.swift index f29d44f..c05f8c1 100644 --- a/Sources/Featurevisor/DatafileReader.swift +++ b/Sources/Featurevisor/DatafileReader.swift @@ -1,6 +1,6 @@ import Foundation -public final class DatafileReader: @unchecked Sendable { +final class DatafileReader: @unchecked Sendable { private var schemaVersion: String private var revision: String private var segments: [SegmentKey: Segment] @@ -8,7 +8,7 @@ public final class DatafileReader: @unchecked Sendable { private var regexCache: [String: NSRegularExpression] = [:] private let logger: Logger - public init(datafile: DatafileContent, logger: Logger) { + init(datafile: DatafileContent, logger: Logger) { self.schemaVersion = datafile.schemaVersion self.revision = datafile.revision self.segments = datafile.segments @@ -16,11 +16,11 @@ public final class DatafileReader: @unchecked Sendable { self.logger = logger } - public func getRevision() -> String { revision } - public func getSchemaVersion() -> String { schemaVersion } - public func getFeatureKeys() -> [FeatureKey] { Array(features.keys) } - public func getFeature(_ key: FeatureKey) -> Feature? { features[key] } - public func getSegment(_ key: SegmentKey) -> Segment? { + func getRevision() -> String { revision } + func getSchemaVersion() -> String { schemaVersion } + func getFeatureKeys() -> [FeatureKey] { Array(features.keys) } + func getFeature(_ key: FeatureKey) -> Feature? { features[key] } + func getSegment(_ key: SegmentKey) -> Segment? { guard var segment = segments[key] else { return nil } if case .string(let stringified) = segment.conditions, let data = stringified.data(using: .utf8), @@ -31,21 +31,21 @@ public final class DatafileReader: @unchecked Sendable { return segment } - public func getVariableKeys(_ featureKey: FeatureKey) -> [String] { + func getVariableKeys(_ featureKey: FeatureKey) -> [String] { guard let feature = getFeature(featureKey), let schema = feature.variablesSchema else { return [] } return Array(schema.keys) } - public func hasVariations(_ featureKey: FeatureKey) -> Bool { + func hasVariations(_ featureKey: FeatureKey) -> Bool { guard let feature = getFeature(featureKey), let variations = feature.variations else { return false } return !variations.isEmpty } - public func allConditionsAreMatched(_ conditions: Condition, context: Context) -> Bool { + func allConditionsAreMatched(_ conditions: Condition, context: Context) -> Bool { allConditionsMatched(conditions, context: context) } - public func segmentIsMatched(_ segment: Segment, context: Context) -> Bool { + func segmentIsMatched(_ segment: Segment, context: Context) -> Bool { switch segment.conditions { case .tree(let condition): return allConditionsAreMatched(condition, context: context) @@ -58,7 +58,7 @@ public final class DatafileReader: @unchecked Sendable { } } - public func allSegmentsAreMatched(_ groupSegments: GroupSegment, context: Context) -> Bool { + func allSegmentsAreMatched(_ groupSegments: GroupSegment, context: Context) -> Bool { switch groupSegments { case .all: return true @@ -80,11 +80,11 @@ public final class DatafileReader: @unchecked Sendable { } } - public func getMatchedTraffic(_ traffic: [Traffic], context: Context) -> Traffic? { + func getMatchedTraffic(_ traffic: [Traffic], context: Context) -> Traffic? { traffic.first(where: { allSegmentsAreMatched($0.segments, context: context) }) } - public func getMatchedAllocation(_ traffic: Traffic, bucketValue: Int) -> Allocation? { + func getMatchedAllocation(_ traffic: Traffic, bucketValue: Int) -> Allocation? { guard let allocations = traffic.allocation else { return nil } for item in allocations { guard item.range.count == 2 else { continue } @@ -97,7 +97,7 @@ public final class DatafileReader: @unchecked Sendable { return nil } - public func getMatchedForce(_ feature: Feature, context: Context) -> (force: Force?, index: Int?) { + func getMatchedForce(_ feature: Feature, context: Context) -> (force: Force?, index: Int?) { guard let forces = feature.force else { return (nil, nil) } for (index, force) in forces.enumerated() { diff --git a/Sources/Featurevisor/Evaluate.swift b/Sources/Featurevisor/Evaluate.swift index c048212..f69f92c 100644 --- a/Sources/Featurevisor/Evaluate.swift +++ b/Sources/Featurevisor/Evaluate.swift @@ -4,12 +4,12 @@ public struct EvaluateDependencies: Sendable { public var context: Context public var logger: Logger public var modulesManager: ModulesManager - public var datafileReader: DatafileReader + var datafileReader: DatafileReader public var sticky: StickyFeatures? public var defaultVariationValue: VariationValue? public var defaultVariableValue: VariableValue? - public init( + init( context: Context, logger: Logger, modulesManager: ModulesManager, @@ -34,7 +34,7 @@ public struct EvaluateOptions: Sendable { public var variableKey: VariableKey? public var dependencies: EvaluateDependencies - public init(type: EvaluationType, featureKey: FeatureKey, variableKey: VariableKey? = nil, dependencies: EvaluateDependencies) { + init(type: EvaluationType, featureKey: FeatureKey, variableKey: VariableKey? = nil, dependencies: EvaluateDependencies) { self.type = type self.featureKey = featureKey self.variableKey = variableKey @@ -42,7 +42,7 @@ public struct EvaluateOptions: Sendable { } } -public func evaluateWithModules(_ options: EvaluateOptions) -> Evaluation { +func evaluateWithModules(_ options: EvaluateOptions) -> Evaluation { var updated = options for module in updated.dependencies.modulesManager.getAll() { if let before = module.before { @@ -71,7 +71,7 @@ public func evaluateWithModules(_ options: EvaluateOptions) -> Evaluation { return evaluation } -public func evaluate(_ options: EvaluateOptions) -> Evaluation { +func evaluate(_ options: EvaluateOptions) -> Evaluation { let type = options.type let featureKey = options.featureKey let variableKey = options.variableKey diff --git a/Sources/Featurevisor/Events.swift b/Sources/Featurevisor/Events.swift index b33b58d..0e77323 100644 --- a/Sources/Featurevisor/Events.swift +++ b/Sources/Featurevisor/Events.swift @@ -15,7 +15,7 @@ public func getParamsForStickySetEvent( ] } -public func getParamsForDatafileSetEvent( +func getParamsForDatafileSetEvent( previousDatafileReader: DatafileReader, newDatafileReader: DatafileReader, replace: Bool diff --git a/Sources/Featurevisor/Instance.swift b/Sources/Featurevisor/Instance.swift index 3a0236d..ad8f674 100644 --- a/Sources/Featurevisor/Instance.swift +++ b/Sources/Featurevisor/Instance.swift @@ -1,6 +1,6 @@ import Foundation -public struct InstanceOptions: Sendable { +public struct FeaturevisorOptions: Sendable { public var datafile: DatafileContent? public var context: Context public var logLevel: LogLevel @@ -51,7 +51,7 @@ public final class FeaturevisorInstance: @unchecked Sendable { private let emitter: Emitter private var closed = false - public init(options: InstanceOptions) { + public init(options: FeaturevisorOptions) { self.context = options.context self.logger = options.logger ?? createLogger(level: options.logLevel) self.logLevel = options.logLevel @@ -357,6 +357,6 @@ private func mergeStoredDatafile(existing: DatafileContent, incoming: DatafileCo ) } -public func createInstance(_ options: InstanceOptions = InstanceOptions()) -> FeaturevisorInstance { +public func createInstance(_ options: FeaturevisorOptions = FeaturevisorOptions()) -> FeaturevisorInstance { FeaturevisorInstance(options: options) } diff --git a/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift b/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift index 2b45112..d5a3c3e 100644 --- a/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift @@ -36,7 +36,7 @@ struct AssessDistributionCommand { return 1 } - let sdk = createInstance(InstanceOptions(datafile: datafile, logLevel: CLIHelpers.loggerLevel(options))) + let sdk = createInstance(FeaturevisorOptions(datafile: datafile, logLevel: CLIHelpers.loggerLevel(options))) let baseContext = CLIHelpers.parseContext(options.context) var flagCounts: [String: Int] = ["enabled": 0, "disabled": 0] diff --git a/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift b/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift index 40d3659..4a7eb92 100644 --- a/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift @@ -50,7 +50,7 @@ struct BenchmarkCommand { return 1 } - let sdk = createInstance(InstanceOptions(datafile: datafile, logLevel: CLIHelpers.loggerLevel(options))) + let sdk = createInstance(FeaturevisorOptions(datafile: datafile, logLevel: CLIHelpers.loggerLevel(options))) print("\nRunning benchmark for feature \"\(options.feature)\"...") print("Against context: \(options.context.isEmpty ? "{}" : options.context)") diff --git a/Sources/FeaturevisorCLI/Commands/TestCommand.swift b/Sources/FeaturevisorCLI/Commands/TestCommand.swift index e8a101c..20fb4cc 100644 --- a/Sources/FeaturevisorCLI/Commands/TestCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/TestCommand.swift @@ -155,7 +155,7 @@ struct TestCommand { }) return createInstance( - InstanceOptions( + FeaturevisorOptions( datafile: datafile, logLevel: CLIHelpers.loggerLevel(options), sticky: sticky, diff --git a/Tests/FeaturevisorTests/ChildTests.swift b/Tests/FeaturevisorTests/ChildTests.swift index b4e1353..64e5c7b 100644 --- a/Tests/FeaturevisorTests/ChildTests.swift +++ b/Tests/FeaturevisorTests/ChildTests.swift @@ -3,7 +3,7 @@ import XCTest final class ChildTests: XCTestCase { func testChildContextAndSticky() { - let sdk = createInstance(InstanceOptions(datafile: TestFixtures.basicDatafile(), context: ["app": .string("ios")])) + let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile(), context: ["app": .string("ios")])) let child = sdk.spawn(["userId": .string("123")]) XCTAssertEqual(child.getContext()["app"], .string("ios")) @@ -14,7 +14,7 @@ final class ChildTests: XCTestCase { } func testChildDelegatesParentEventsForDatafileSet() { - let sdk = createInstance(InstanceOptions(datafile: TestFixtures.basicDatafile())) + let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) let child = sdk.spawn() let called = ConcurrencyBox(false) diff --git a/Tests/FeaturevisorTests/FeaturevisorTests.swift b/Tests/FeaturevisorTests/FeaturevisorTests.swift index 5639307..cbac1b9 100644 --- a/Tests/FeaturevisorTests/FeaturevisorTests.swift +++ b/Tests/FeaturevisorTests/FeaturevisorTests.swift @@ -49,7 +49,7 @@ final class FeaturevisorTests: XCTestCase { ] ) - let sdk = createInstance(InstanceOptions(datafile: datafile)) + let sdk = createInstance(FeaturevisorOptions(datafile: datafile)) XCTAssertTrue(sdk.isEnabled("test", ["userId": .string("123")])) } @@ -97,7 +97,7 @@ final class FeaturevisorTests: XCTestCase { ] ) - let sdk = createInstance(InstanceOptions(datafile: datafile)) + let sdk = createInstance(FeaturevisorOptions(datafile: datafile)) XCTAssertEqual(sdk.getVariableString("test", "color", ["userId": .string("123")]), "red") } } diff --git a/Tests/FeaturevisorTests/InstanceTests.swift b/Tests/FeaturevisorTests/InstanceTests.swift index 6618ec9..d124bdd 100644 --- a/Tests/FeaturevisorTests/InstanceTests.swift +++ b/Tests/FeaturevisorTests/InstanceTests.swift @@ -3,7 +3,7 @@ import XCTest final class InstanceTests: XCTestCase { func testLifecycleAndEvaluations() { - let sdk = createInstance(InstanceOptions(datafile: TestFixtures.basicDatafile())) + let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) XCTAssertEqual(sdk.getRevision(), "1") XCTAssertTrue(sdk.isEnabled("test", ["userId": .string("123")])) @@ -16,7 +16,7 @@ final class InstanceTests: XCTestCase { } func testSetContextAndStickyEvents() { - let sdk = createInstance(InstanceOptions(datafile: TestFixtures.basicDatafile())) + let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) let contextEvent = ConcurrencyBox(false) let stickyEvent = ConcurrencyBox(false) @@ -37,7 +37,7 @@ final class InstanceTests: XCTestCase { } func testSetDatafileMergesByDefaultAndReplacesWhenRequested() { - let sdk = createInstance(InstanceOptions(datafile: TestFixtures.basicDatafile())) + let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) let secondDatafile = DatafileContent( schemaVersion: "2", @@ -72,7 +72,7 @@ final class InstanceTests: XCTestCase { } func testDatafileSetEventIncludesReplaced() { - let sdk = createInstance(InstanceOptions(datafile: TestFixtures.basicDatafile())) + let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) let replaced = ConcurrencyBox(nil) let unsubscribe = sdk.on(.datafileSet) { payload in diff --git a/Tests/FeaturevisorTests/ModulesTests.swift b/Tests/FeaturevisorTests/ModulesTests.swift index d4b45fb..3d23c41 100644 --- a/Tests/FeaturevisorTests/ModulesTests.swift +++ b/Tests/FeaturevisorTests/ModulesTests.swift @@ -30,7 +30,7 @@ final class ModulesTests: XCTestCase { } ) - let sdk = createInstance(InstanceOptions( + let sdk = createInstance(FeaturevisorOptions( datafile: TestFixtures.basicDatafile(), logLevel: .info, onDiagnostic: { diagnostic in @@ -57,7 +57,7 @@ final class ModulesTests: XCTestCase { func testRemovedModulesAreClosed() { let closed = ConcurrencyBox<[String]>([]) - let sdk = createInstance(InstanceOptions(logLevel: .fatal)) + let sdk = createInstance(FeaturevisorOptions(logLevel: .fatal)) let unsubscribe = sdk.addModule(FeaturevisorModule( name: "dynamic", @@ -86,7 +86,7 @@ final class ModulesTests: XCTestCase { let errorEventCodes = ConcurrencyBox<[String]>([]) let closed = ConcurrencyBox<[String]>([]) - let sdk = createInstance(InstanceOptions( + let sdk = createInstance(FeaturevisorOptions( logLevel: .info, onDiagnostic: { diagnostic in diagnostics.value.append(diagnostic) @@ -127,7 +127,7 @@ final class ModulesTests: XCTestCase { func testModuleUnsubscribeReportsCloseErrors() { let diagnostics = ConcurrencyBox<[FeaturevisorDiagnostic]>([]) - let sdk = createInstance(InstanceOptions( + let sdk = createInstance(FeaturevisorOptions( logLevel: .info, onDiagnostic: { diagnostic in diagnostics.value.append(diagnostic) @@ -193,7 +193,7 @@ final class ModulesTests: XCTestCase { ] ) - let sdk = createInstance(InstanceOptions(datafile: datafile, modules: [module])) + let sdk = createInstance(FeaturevisorOptions(datafile: datafile, modules: [module])) let evaluation = sdk.evaluateFlag("exp") XCTAssertEqual(bucketKeySeen.value, "from-before.exp") diff --git a/Tests/FeaturevisorTests/ParityBehaviorTests.swift b/Tests/FeaturevisorTests/ParityBehaviorTests.swift index f60d169..e0e0342 100644 --- a/Tests/FeaturevisorTests/ParityBehaviorTests.swift +++ b/Tests/FeaturevisorTests/ParityBehaviorTests.swift @@ -44,7 +44,7 @@ final class ParityBehaviorTests: XCTestCase { ] ) - let sdk = createInstance(InstanceOptions(datafile: datafile)) + let sdk = createInstance(FeaturevisorOptions(datafile: datafile)) XCTAssertTrue(sdk.isEnabled("dependent", ["userId": .string("1")])) } @@ -100,7 +100,7 @@ final class ParityBehaviorTests: XCTestCase { ] ) - let sdk = createInstance(InstanceOptions(datafile: datafile)) + let sdk = createInstance(FeaturevisorOptions(datafile: datafile)) XCTAssertEqual(sdk.getVariable("feature", "title", ["userId": .string("1"), "country": .string("de")]), .string("rule-override")) XCTAssertEqual(sdk.getVariable("feature", "title", ["userId": .string("1"), "country": .string("nl")]), .string("variation-override")) XCTAssertEqual(sdk.getVariable("feature", "title", ["userId": .string("1"), "country": .string("fr")]), .string("variation")) @@ -129,7 +129,7 @@ final class ParityBehaviorTests: XCTestCase { ) let module = FeaturevisorModule(name: "force-bucket", bucketValue: { _ in 50_000 }) - let sdk = createInstance(InstanceOptions(datafile: datafile, modules: [module])) + let sdk = createInstance(FeaturevisorOptions(datafile: datafile, modules: [module])) let evaluation = sdk.evaluateFlag("exp", context: ["userId": .string("1")]) XCTAssertEqual(evaluation.reason, .outOfRange) @@ -161,7 +161,7 @@ final class ParityBehaviorTests: XCTestCase { ] ) - let sdk = createInstance(InstanceOptions(datafile: datafile)) + let sdk = createInstance(FeaturevisorOptions(datafile: datafile)) XCTAssertEqual(sdk.getVariation("disabled", ["userId": .string("1")]), "off") XCTAssertEqual(sdk.getVariable("disabled", "a", ["userId": .string("1")]), .string("A-default")) XCTAssertEqual(sdk.getVariable("disabled", "b", ["userId": .string("1")]), .string("B-disabled")) From 1d4a6627933cb7fbe63426f45ba57f5dd184ea2a Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Tue, 7 Jul 2026 23:17:17 +0200 Subject: [PATCH 05/13] benchmark --- .../Commands/BenchmarkCommand.swift | 36 ++++++++++++++++--- .../Support/FeaturevisorProcess.swift | 32 +++++++++++++---- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift b/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift index 4a7eb92..a27246d 100644 --- a/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift @@ -4,6 +4,9 @@ import Featurevisor struct BenchmarkOutput { let value: AnyValue? let duration: TimeInterval + let minDuration: TimeInterval + let averageDuration: TimeInterval + let maxDuration: TimeInterval } struct BenchmarkCommand { @@ -25,10 +28,33 @@ struct BenchmarkCommand { } private func benchmark(_ n: Int, _ block: () -> AnyValue?) -> BenchmarkOutput { - let start = Date() var value: AnyValue? - for _ in 0.. maxNanoseconds { maxNanoseconds = duration } + } + + let totalSeconds = Double(totalNanoseconds) / 1_000_000_000 + return BenchmarkOutput( + value: value, + duration: totalSeconds, + minDuration: Double(minNanoseconds ?? 0) / 1_000_000_000, + averageDuration: totalSeconds / Double(n), + maxDuration: Double(maxNanoseconds) / 1_000_000_000 + ) + } + + private func formatDurationMs(_ seconds: TimeInterval) -> String { + String(format: "%.6fms", seconds * 1000) } func run(_ options: CLIOptions) -> Int32 { @@ -84,7 +110,9 @@ struct BenchmarkCommand { print("\nEvaluated value : \(valueString)") print("Total duration : \(prettyDuration(output.duration))") - print("Average duration: \(prettyDuration(output.duration / Double(options.n)))") + print("Minimum duration: \(formatDurationMs(output.minDuration))") + print("Average duration: \(formatDurationMs(output.averageDuration))") + print("Maximum duration: \(formatDurationMs(output.maxDuration))") return 0 } diff --git a/Sources/FeaturevisorCLI/Support/FeaturevisorProcess.swift b/Sources/FeaturevisorCLI/Support/FeaturevisorProcess.swift index 0f7ea1e..a4292da 100644 --- a/Sources/FeaturevisorCLI/Support/FeaturevisorProcess.swift +++ b/Sources/FeaturevisorCLI/Support/FeaturevisorProcess.swift @@ -13,20 +13,40 @@ enum FeaturevisorProcess { process.arguments = ["npx", "featurevisor"] + args process.currentDirectoryURL = URL(fileURLWithPath: projectDirectoryPath) - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe + let temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let stdoutURL = temporaryDirectory.appendingPathComponent("featurevisor-swift-stdout-\(UUID().uuidString)") + let stderrURL = temporaryDirectory.appendingPathComponent("featurevisor-swift-stderr-\(UUID().uuidString)") + + FileManager.default.createFile(atPath: stdoutURL.path, contents: nil) + FileManager.default.createFile(atPath: stderrURL.path, contents: nil) + + guard let stdoutHandle = try? FileHandle(forWritingTo: stdoutURL), + let stderrHandle = try? FileHandle(forWritingTo: stderrURL) else { + return ProcessResult(code: 1, stdout: "", stderr: "Could not create temporary process output files") + } + + process.standardOutput = stdoutHandle + process.standardError = stderrHandle do { try process.run() process.waitUntilExit() } catch { + try? stdoutHandle.close() + try? stderrHandle.close() + try? FileManager.default.removeItem(at: stdoutURL) + try? FileManager.default.removeItem(at: stderrURL) return ProcessResult(code: 1, stdout: "", stderr: error.localizedDescription) } - let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + try? stdoutHandle.close() + try? stderrHandle.close() + + let outData = (try? Data(contentsOf: stdoutURL)) ?? Data() + let errData = (try? Data(contentsOf: stderrURL)) ?? Data() + + try? FileManager.default.removeItem(at: stdoutURL) + try? FileManager.default.removeItem(at: stderrURL) return ProcessResult( code: process.terminationStatus, From 147b2810411b45cf3ddbaea13549de9601e06a29 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 9 Jul 2026 21:52:16 +0200 Subject: [PATCH 06/13] updates --- Sources/Featurevisor/Instance.swift | 9 +++++++++ Tests/FeaturevisorTests/InstanceTests.swift | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/Sources/Featurevisor/Instance.swift b/Sources/Featurevisor/Instance.swift index ad8f674..1543cd1 100644 --- a/Sources/Featurevisor/Instance.swift +++ b/Sources/Featurevisor/Instance.swift @@ -247,6 +247,15 @@ public final class FeaturevisorInstance: @unchecked Sendable { "context": .object(self.context), "replaced": .bool(replace), ])) + reportDiagnostic(FeaturevisorDiagnostic( + level: .debug, + code: "context_set", + message: replace ? "Context replaced" : "Context updated", + details: [ + "context": .object(self.context), + "replaced": .bool(replace), + ] + )) } public func getContext(_ context: Context? = nil) -> Context { diff --git a/Tests/FeaturevisorTests/InstanceTests.swift b/Tests/FeaturevisorTests/InstanceTests.swift index d124bdd..6e110bc 100644 --- a/Tests/FeaturevisorTests/InstanceTests.swift +++ b/Tests/FeaturevisorTests/InstanceTests.swift @@ -36,6 +36,24 @@ final class InstanceTests: XCTestCase { XCTAssertTrue(stickyEvent.value) } + func testLifecycleMutationsReportDiagnostics() { + let diagnostics = ConcurrencyBox<[String]>([]) + let sdk = createInstance(FeaturevisorOptions( + logLevel: .debug, + onDiagnostic: { diagnostic in + diagnostics.value.append(diagnostic.code) + } + )) + + sdk.setDatafile(TestFixtures.basicDatafile()) + sdk.setSticky(["test": EvaluatedFeature(enabled: true)]) + sdk.setContext(["country": .string("nl")]) + + XCTAssertTrue(diagnostics.value.contains("datafile_set")) + XCTAssertTrue(diagnostics.value.contains("sticky_set")) + XCTAssertTrue(diagnostics.value.contains("context_set")) + } + func testSetDatafileMergesByDefaultAndReplacesWhenRequested() { let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) From 656d1a9ae554a25f4276068bfc3ca63150ac212a Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 9 Jul 2026 22:58:54 +0200 Subject: [PATCH 07/13] diagnostics --- README.md | 4 ++++ Sources/Featurevisor/Child.swift | 5 +++-- Sources/Featurevisor/Evaluation.swift | 15 ++++++++++++--- Sources/Featurevisor/Instance.swift | 4 ++-- .../FeaturevisorCLI/Commands/TestCommand.swift | 2 +- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 53967fb..9d20418 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,8 @@ This is handy especially when you want to pass all evaluations from a backend ap For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/): +Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; use `SpawnOptions(sticky: ...)` when a child needs its own sticky state. + ### Initialize with sticky ```swift @@ -434,6 +436,8 @@ let f = createInstance( Modules can also subscribe to diagnostics or report their own diagnostics from `setup` using the provided module API. +Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` dictionary. Optional `module`, `moduleName`, and `originalError` fields describe provenance; evaluation metadata belongs in `details`. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to runtime events. diff --git a/Sources/Featurevisor/Child.swift b/Sources/Featurevisor/Child.swift index 8f125b2..baa03f9 100644 --- a/Sources/Featurevisor/Child.swift +++ b/Sources/Featurevisor/Child.swift @@ -48,11 +48,12 @@ public final class FeaturevisorChildInstance: @unchecked Sendable { } private func merge(_ options: OverrideOptions) -> OverrideOptions { - OverrideOptions( - sticky: options.sticky == nil ? sticky : (sticky ?? [:]).merging(options.sticky ?? [:], uniquingKeysWith: { _, new in new }), + var merged = OverrideOptions( defaultVariationValue: options.defaultVariationValue, defaultVariableValue: options.defaultVariableValue ) + merged.sticky = sticky + return merged } public func evaluateFlag(_ featureKey: FeatureKey, context: Context = [:], options: OverrideOptions = OverrideOptions()) -> Evaluation { diff --git a/Sources/Featurevisor/Evaluation.swift b/Sources/Featurevisor/Evaluation.swift index 35d5418..312ab31 100644 --- a/Sources/Featurevisor/Evaluation.swift +++ b/Sources/Featurevisor/Evaluation.swift @@ -56,17 +56,26 @@ public struct Evaluation: Codable, Sendable { } public struct OverrideOptions: Sendable { - public var sticky: StickyFeatures? + // Instance-internal transport for child sticky state. Sticky values are + // configured on an instance, never supplied for an individual evaluation. + var sticky: StickyFeatures? public var defaultVariationValue: VariationValue? public var defaultVariableValue: VariableValue? public init( - sticky: StickyFeatures? = nil, defaultVariationValue: VariationValue? = nil, defaultVariableValue: VariableValue? = nil ) { - self.sticky = sticky + self.sticky = nil self.defaultVariationValue = defaultVariationValue self.defaultVariableValue = defaultVariableValue } } + +public struct SpawnOptions: Sendable { + public var sticky: StickyFeatures? + + public init(sticky: StickyFeatures? = nil) { + self.sticky = sticky + } +} diff --git a/Sources/Featurevisor/Instance.swift b/Sources/Featurevisor/Instance.swift index 1543cd1..f9a6eb7 100644 --- a/Sources/Featurevisor/Instance.swift +++ b/Sources/Featurevisor/Instance.swift @@ -263,7 +263,7 @@ public final class FeaturevisorInstance: @unchecked Sendable { return self.context.merging(context, uniquingKeysWith: { _, new in new }) } - public func spawn(_ context: Context = [:], options: OverrideOptions = OverrideOptions()) -> FeaturevisorChildInstance { + public func spawn(_ context: Context = [:], options: SpawnOptions = SpawnOptions()) -> FeaturevisorChildInstance { FeaturevisorChildInstance(parent: self, context: getContext(context), sticky: options.sticky) } @@ -273,7 +273,7 @@ public final class FeaturevisorInstance: @unchecked Sendable { logger: logger, modulesManager: modulesManager, datafileReader: datafileReader, - sticky: options.sticky == nil ? sticky : (sticky ?? [:]).merging(options.sticky ?? [:], uniquingKeysWith: { _, new in new }), + sticky: options.sticky ?? sticky, defaultVariationValue: options.defaultVariationValue, defaultVariableValue: options.defaultVariableValue ) diff --git a/Sources/FeaturevisorCLI/Commands/TestCommand.swift b/Sources/FeaturevisorCLI/Commands/TestCommand.swift index 20fb4cc..b9b0c29 100644 --- a/Sources/FeaturevisorCLI/Commands/TestCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/TestCommand.swift @@ -293,7 +293,7 @@ struct TestCommand { } let childContext = CLIHelpers.parseContext(childContextMap) let childSticky = CLIHelpers.anyToSticky(assertion["sticky"]) - let child = sdk.spawn(childContext, options: OverrideOptions(sticky: childSticky)) + let child = sdk.spawn(childContext, options: SpawnOptions(sticky: childSticky)) if let expectedEnabled = childAssertion["expectedToBeEnabled"] as? Bool, child.isEnabled(featureKey) != expectedEnabled { From dcbf981f45ecba7ef0567446d1135442f4fbaa41 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 11 Jul 2026 18:45:30 +0200 Subject: [PATCH 08/13] improvements --- README.md | 2 + Sources/Featurevisor/Helpers.swift | 13 +++-- .../DatafileReaderTests.swift | 28 +++++++++++ Tests/FeaturevisorTests/HelpersTests.swift | 6 ++- conformance/sdk-v3.json | 49 +++++++++++++++++++ 5 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 conformance/sdk-v3.json diff --git a/README.md b/README.md index 9d20418..5013304 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,8 @@ f.getVariableObject(featureKey, variableKey, context) f.getVariableJSON(featureKey, variableKey, context) ``` +Type specific methods do not coerce strings or booleans into numbers. They return `nil` when the value does not match the requested type. + ## Getting all evaluations You can get evaluations of all features available in the SDK instance: diff --git a/Sources/Featurevisor/Helpers.swift b/Sources/Featurevisor/Helpers.swift index 720fb70..ffa18db 100644 --- a/Sources/Featurevisor/Helpers.swift +++ b/Sources/Featurevisor/Helpers.swift @@ -7,18 +7,17 @@ public func getValueByType(_ value: AnyValue?, fieldType: String) -> AnyValue? { case "string": return value.asString().map(AnyValue.string) case "integer": - if let int = value.asInt() { return .int(int) } - if case .string(let str) = value { - if let parsed = Int(str) { return .int(parsed) } - if let asDouble = Double(str) { return .int(Int(asDouble)) } + if case .int(let int) = value { return .int(int) } + if case .double(let double) = value, double.isFinite, double.rounded(.towardZero) == double { + return .int(Int(double)) } return nil case "double": - if let double = value.asDouble() { return .double(double) } - if case .string(let str) = value, let parsed = Double(str) { return .double(parsed) } + if case .double(let double) = value, double.isFinite { return .double(double) } + if case .int(let int) = value { return .double(Double(int)) } return nil case "boolean": - return .bool(value.asBool() == true) + return value.asBool().map(AnyValue.bool) case "array": return value.asArray().map(AnyValue.array) case "object": diff --git a/Tests/FeaturevisorTests/DatafileReaderTests.swift b/Tests/FeaturevisorTests/DatafileReaderTests.swift index 38bf855..1e4884d 100644 --- a/Tests/FeaturevisorTests/DatafileReaderTests.swift +++ b/Tests/FeaturevisorTests/DatafileReaderTests.swift @@ -2,6 +2,34 @@ import XCTest @testable import Featurevisor final class DatafileReaderTests: XCTestCase { + func testSharedV3ConformanceFixture() throws { + let data = try Data(contentsOf: URL(fileURLWithPath: "conformance/sdk-v3.json")) + let fixture = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(fixture["version"] as? Int, 1) + + let bucketing = fixture["bucketing"] as! [String: Any] + let expected = bucketing["allocationExpectations"] as! [String: String] + let reader = DatafileReader(datafile: TestFixtures.basicDatafile(), logger: createLogger(level: .fatal)) + let traffic = Traffic( + key: "all", + segments: .all, + percentage: 100000, + enabled: nil, + variation: nil, + variables: nil, + variationWeights: nil, + variableOverrides: nil, + allocation: [ + Allocation(variation: "control", range: [0, 50000]), + Allocation(variation: "treatment", range: [50000, 100000]), + ] + ) + + for (bucket, variation) in expected { + XCTAssertEqual(reader.getMatchedAllocation(traffic, bucketValue: Int(bucket)!)?.variation, variation) + } + } + func testGetters() { let logger = createLogger(level: .fatal) let reader = DatafileReader(datafile: TestFixtures.basicDatafile(), logger: logger) diff --git a/Tests/FeaturevisorTests/HelpersTests.swift b/Tests/FeaturevisorTests/HelpersTests.swift index 539906f..d71ca0a 100644 --- a/Tests/FeaturevisorTests/HelpersTests.swift +++ b/Tests/FeaturevisorTests/HelpersTests.swift @@ -3,8 +3,10 @@ import XCTest final class HelpersTests: XCTestCase { func testGetValueByType() { - XCTAssertEqual(getValueByType(.string("10"), fieldType: "integer"), .int(10)) - XCTAssertEqual(getValueByType(.string("10.2"), fieldType: "double"), .double(10.2)) + XCTAssertNil(getValueByType(.string("10"), fieldType: "integer")) + XCTAssertNil(getValueByType(.string("10.2"), fieldType: "double")) + XCTAssertNil(getValueByType(.double(10.2), fieldType: "integer")) + XCTAssertNil(getValueByType(.string("true"), fieldType: "boolean")) XCTAssertEqual(getValueByType(.bool(true), fieldType: "boolean"), .bool(true)) XCTAssertEqual(getValueByType(.string("a"), fieldType: "string"), .string("a")) } diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json new file mode 100644 index 0000000..ceae692 --- /dev/null +++ b/conformance/sdk-v3.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "description": "Featurevisor v3 cross SDK compatibility contracts", + "bucketing": { + "minimum": 0, + "maximum": 100000, + "percentage": { + "percentage": 50000, + "enabledAt": [0, 50000], + "disabledAt": [50001, 100000] + }, + "allocations": [ + { "variation": "control", "range": [0, 50000] }, + { "variation": "treatment", "range": [50000, 100000] } + ], + "allocationExpectations": { + "0": "control", + "49999": "control", + "50000": "control", + "50001": "treatment", + "99999": "treatment", + "100000": "treatment" + } + }, + "regularExpressions": { + "pattern": "chrome", + "flags": "g", + "values": ["chrome", "chrome", "firefox", "chrome"], + "matches": [true, true, false, true] + }, + "typedVariables": [ + { "type": "integer", "value": 1, "valid": true }, + { "type": "integer", "value": 1.5, "valid": false }, + { "type": "integer", "value": "1", "valid": false }, + { "type": "double", "value": 1.5, "valid": true }, + { "type": "double", "value": "1.5", "valid": false }, + { "type": "boolean", "value": true, "valid": true }, + { "type": "boolean", "value": "true", "valid": false } + ], + "datafile": { + "schemaVersionIsInformational": true, + "schemaVersionType": "string" + }, + "diagnostics": { + "requiredFields": ["level", "code", "message", "details"], + "detailsType": "object", + "emptyDetailsJson": "{}" + } +} From 23994bdc342f7cafd9ec0ef4a035efd0674371e6 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 12:33:34 +0200 Subject: [PATCH 09/13] cli alignment --- README.md | 2 ++ .../Commands/AssessDistributionCommand.swift | 20 ++++++++++++-- .../Commands/BenchmarkCommand.swift | 20 ++++++++++++-- .../Commands/TestCommand.swift | 27 +++++++++++++++++-- .../FeaturevisorCLI/Support/CLIOptions.swift | 2 ++ .../FeaturevisorCLITests.swift | 5 ++++ 6 files changed, 70 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5013304..0225bfb 100644 --- a/README.md +++ b/README.md @@ -569,6 +569,8 @@ f.close() The package also ships an executable named `featurevisor`. +All three commands accept repeatable `--target=` options. `test` builds only the selected Target datafiles and runs untargeted assertions plus assertions for those targets. `benchmark` and `assess-distribution` run independently against every selected Target datafile. Without `--target`, existing project-wide behavior is preserved. Project definitions, test specs, Target discovery, and datafile generation continue to come from the Node.js CLI. + ### Test ```bash diff --git a/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift b/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift index d5a3c3e..41d76b9 100644 --- a/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift @@ -28,10 +28,22 @@ struct AssessDistributionCommand { return 1 } + if options.targets.count > 1 { + for target in options.targets { + var selected = options + selected.targets = [target] + if run(selected) != 0 { return 1 } + } + return 0 + } + + let target = options.targets.first + guard let datafile = CLIHelpers.buildDatafileJSON( projectDirectoryPath: options.projectDirectoryPath, environment: options.environment, - inflate: options.inflate + inflate: options.inflate, + target: target ) else { return 1 } @@ -42,7 +54,11 @@ struct AssessDistributionCommand { var flagCounts: [String: Int] = ["enabled": 0, "disabled": 0] var variationCounts: [String: Int] = [:] - print("\nAssessing distribution for feature: \(options.feature)...") + print("\nAssess Featurevisor distribution") + print(" Feature: \(options.feature)") + print(" Environment: \(options.environment)") + if let target { print(" Target: \(target)") } + print(" Iterations: \(options.n)") print("Against context: \(options.context.isEmpty ? "{}" : options.context)") print("Running \(options.n) times...") diff --git a/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift b/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift index a27246d..f35f9ea 100644 --- a/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift @@ -67,18 +67,34 @@ struct BenchmarkCommand { return 1 } + if options.targets.count > 1 { + for target in options.targets { + var selected = options + selected.targets = [target] + if run(selected) != 0 { return 1 } + } + return 0 + } + + let target = options.targets.first + let context = CLIHelpers.parseContext(options.context) guard let datafile = CLIHelpers.buildDatafileJSON( projectDirectoryPath: options.projectDirectoryPath, environment: options.environment, - inflate: options.inflate + inflate: options.inflate, + target: target ) else { return 1 } let sdk = createInstance(FeaturevisorOptions(datafile: datafile, logLevel: CLIHelpers.loggerLevel(options))) - print("\nRunning benchmark for feature \"\(options.feature)\"...") + print("\nBenchmark Featurevisor feature") + print(" Feature: \(options.feature)") + print(" Environment: \(options.environment)") + if let target { print(" Target: \(target)") } + print(" Iterations: \(options.n)") print("Against context: \(options.context.isEmpty ? "{}" : options.context)") let output: BenchmarkOutput diff --git a/Sources/FeaturevisorCLI/Commands/TestCommand.swift b/Sources/FeaturevisorCLI/Commands/TestCommand.swift index b9b0c29..09bfb78 100644 --- a/Sources/FeaturevisorCLI/Commands/TestCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/TestCommand.swift @@ -21,6 +21,13 @@ struct TestCommand { } let segments = loadSegments(options) + if !options.targets.isEmpty { + let availableTargets = loadTargetKeys(options) + if let unknown = options.targets.first(where: { !availableTargets.contains($0) }) { + print("Unknown target \"\(unknown)\". Available targets: \(availableTargets.isEmpty ? "none" : availableTargets.joined(separator: ", ")).") + return 1 + } + } let datafileCache = buildDatafileCache(options: options, config: config) var testArgs = ["list", "--tests", "--applyMatrix", "--json"] @@ -35,6 +42,14 @@ struct TestCommand { for test in tests { if let featureKey = test["feature"] as? String { + if !options.targets.isEmpty, + let assertions = test["assertions"] as? [[String: Any]], + !assertions.contains(where: { assertion in + guard let target = assertion["target"] as? String else { return true } + return options.targets.contains(target) + }) { + continue + } let result = runFeatureTest( featureKey: featureKey, test: test, @@ -118,7 +133,8 @@ struct TestCommand { var cache: [String: DatafileContent] = [:] let envs = CLIHelpers.stringArray(config["environments"]) let environments: [String?] = envs.isEmpty ? [nil] : envs.map(Optional.some) - let targetKeys = loadTargetKeys(options) + let availableTargets = loadTargetKeys(options) + let targetKeys = options.targets.isEmpty ? availableTargets : options.targets for environment in environments { guard let base = CLIHelpers.buildDatafileJSON( @@ -170,9 +186,16 @@ struct TestCommand { options: CLIOptions, datafileCache: [String: DatafileContent] ) -> (ok: Bool, passed: Int, failed: Int) { - guard let assertions = test["assertions"] as? [[String: Any]] else { + guard var assertions = test["assertions"] as? [[String: Any]] else { return (false, 0, 1) } + if !options.targets.isEmpty { + assertions = assertions.filter { assertion in + guard let target = assertion["target"] as? String else { return true } + return options.targets.contains(target) + } + if assertions.isEmpty { return (true, 0, 0) } + } let testKey = (test["key"] as? String) ?? featureKey var passed = 0 diff --git a/Sources/FeaturevisorCLI/Support/CLIOptions.swift b/Sources/FeaturevisorCLI/Support/CLIOptions.swift index a4ff924..50b8955 100644 --- a/Sources/FeaturevisorCLI/Support/CLIOptions.swift +++ b/Sources/FeaturevisorCLI/Support/CLIOptions.swift @@ -20,6 +20,7 @@ struct CLIOptions { var schemaVersion: String = "" var projectDirectoryPath: String = FileManager.default.currentDirectoryPath var populateUuid: [String] = [] + var targets: [String] = [] } enum CLIParser { @@ -55,6 +56,7 @@ enum CLIParser { else if let value = read("--schemaVersion=") { opts.schemaVersion = value } else if let value = read("--projectDirectoryPath=") { opts.projectDirectoryPath = value } else if let value = read("--populateUuid=") { opts.populateUuid.append(value) } + else if let value = read("--target="), !opts.targets.contains(value) { opts.targets.append(value) } } return opts diff --git a/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift b/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift index 10afc96..8ca2363 100644 --- a/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift +++ b/Tests/FeaturevisorCLITests/FeaturevisorCLITests.swift @@ -24,6 +24,11 @@ final class FeaturevisorCLITests: XCTestCase { XCTAssertEqual(opts.projectDirectoryPath, "/tmp/project") } + func testRepeatedTargets() { + let opts = CLIParser.parse(["benchmark", "--target=web", "--target=mobile", "--target=web"]) + XCTAssertEqual(opts.targets, ["web", "mobile"]) + } + func testTargetDatafileCacheKey() { let command = TestCommand() From a9767d4f3c6cae3b4bdbbff3c194c92c7fddb226 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 18:30:01 +0200 Subject: [PATCH 10/13] alignment --- README.md | 80 +++++++------------ Sources/Featurevisor/Child.swift | 4 +- Sources/Featurevisor/Emitter.swift | 2 +- Sources/Featurevisor/Evaluate.swift | 58 ++++++++++++-- Sources/Featurevisor/Instance.swift | 44 ++++++---- Sources/Featurevisor/Logger.swift | 36 +++++---- Sources/Featurevisor/Modules.swift | 2 +- .../Commands/AssessDistributionCommand.swift | 2 +- .../Commands/BenchmarkCommand.swift | 2 +- .../Commands/TestCommand.swift | 4 +- Tests/FeaturevisorTests/ChildTests.swift | 4 +- .../FeaturevisorTests/FeaturevisorTests.swift | 4 +- Tests/FeaturevisorTests/IndexTests.swift | 4 +- Tests/FeaturevisorTests/InstanceTests.swift | 28 +++++-- Tests/FeaturevisorTests/ModulesTests.swift | 10 +-- .../ParityBehaviorTests.swift | 8 +- 16 files changed, 178 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 0225bfb..b8a6859 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje ## Table of contents - [Installation](#installation) +- [Public API](#public-api) - [Initialization](#initialization) - [Evaluation types](#evaluation-types) - [Context](#context) @@ -28,11 +29,9 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) - [Interval-based update](#interval-based-update) -- [Logging](#logging) +- [Diagnostics](#diagnostics) - [Levels](#levels) - - [Customizing levels](#customizing-levels) - [Handler](#handler) -- [Diagnostics](#diagnostics) - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) @@ -68,6 +67,18 @@ Then add the product dependency: .product(name: "Featurevisor", package: "featurevisor-swift2") ``` +## Public API + +The main runtime API is `createFeaturevisor()`: + +```swift +let f: Featurevisor = createFeaturevisor( + FeaturevisorOptions(datafile: datafileContent) +) +``` + +Most applications only need `createFeaturevisor`, `Featurevisor`, and `FeaturevisorOptions`. Public extension and observability types include `FeaturevisorModule`, `FeaturevisorDiagnostic`, and the datafile model types. + ## Initialization The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly: @@ -80,7 +91,7 @@ let datafileURL = URL(string: "https://cdn.yoursite.com/datafile.json")! let data = try Data(contentsOf: datafileURL) let datafileContent = try DatafileContent.fromData(data) -let f = createInstance( +let f = createFeaturevisor( FeaturevisorOptions( datafile: datafileContent ) @@ -117,7 +128,7 @@ let context: Context = [ You can set context at the time of initialization: ```swift -let f = createInstance( +let f = createFeaturevisor( FeaturevisorOptions( context: [ "deviceId": .string("123"), @@ -271,7 +282,7 @@ Sticky values belong to an SDK or child instance. Evaluation options do not acce ### Initialize with sticky ```swift -let f = createInstance( +let f = createFeaturevisor( FeaturevisorOptions( sticky: [ "myFeatureKey": EvaluatedFeature( @@ -338,7 +349,7 @@ Because merging is the default, a single SDK instance can start with a small dat This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: ```swift -let f = createInstance(FeaturevisorOptions()) +let f = createFeaturevisor(FeaturevisorOptions()) func loadDatafile(target: String) { let url = URL(string: "https://cdn.yoursite.com/production/featurevisor-\(target).json")! @@ -373,62 +384,32 @@ Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in } ``` -## Logging +## Diagnostics -By default, Featurevisor SDK logs from `info` level and above. +By default, Featurevisor reports diagnostics to the console for `info` level and above with a `[Featurevisor]` prefix. ### Levels -These are available log levels: - -- `debug` -- `info` -- `warn` -- `error` -- `fatal` - -### Customizing levels +Available diagnostic levels are `fatal`, `error`, `warn`, `info`, and `debug`. -You can set log level at initialization: +Set the level during initialization or update it afterwards: ```swift -let f = createInstance( - FeaturevisorOptions( - logLevel: .debug - ) +let f = createFeaturevisor( + FeaturevisorOptions(logLevel: .debug) ) -``` -Or set it afterwards: - -```swift -f.setLogLevel(.debug) +f.setLogLevel(.info) ``` ### Handler -If you want to fully control log output, pass a custom logger: - -```swift -let logger = createLogger(level: .debug) { level, message, details in - print("[\(level)] \(message) \(details)") -} - -let f = createInstance( - FeaturevisorOptions( - datafile: datafileContent, - logger: logger - ) -) -``` - -## Diagnostics - -You can observe SDK and module diagnostics with `onDiagnostic`: +Use `onDiagnostic` to send structured diagnostics to your observability system: ```swift -let f = createInstance( +let f = createFeaturevisor( FeaturevisorOptions( + logLevel: .info, onDiagnostic: { diagnostic in print(diagnostic.level, diagnostic.code, diagnostic.message) } @@ -438,7 +419,8 @@ let f = createInstance( Modules can also subscribe to diagnostics or report their own diagnostics from `setup` using the provided module API. -Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` dictionary. Optional `module`, `moduleName`, and `originalError` fields describe provenance; evaluation metadata belongs in `details`. +Every diagnostic has `level`, `code`, `message`, and an object-shaped `details` dictionary. Optional `module`, `moduleName`, and `originalError` fields describe provenance. Evaluation metadata belongs in `details`. + ## Events @@ -535,7 +517,7 @@ let module = FeaturevisorModule( ### Registering modules ```swift -let f = createInstance( +let f = createFeaturevisor( FeaturevisorOptions( modules: [module] ) diff --git a/Sources/Featurevisor/Child.swift b/Sources/Featurevisor/Child.swift index baa03f9..088bc29 100644 --- a/Sources/Featurevisor/Child.swift +++ b/Sources/Featurevisor/Child.swift @@ -1,12 +1,12 @@ import Foundation public final class FeaturevisorChildInstance: @unchecked Sendable { - private let parent: FeaturevisorInstance + private let parent: Featurevisor private var context: Context private var sticky: StickyFeatures? private let emitter = Emitter() - init(parent: FeaturevisorInstance, context: Context, sticky: StickyFeatures?) { + init(parent: Featurevisor, context: Context, sticky: StickyFeatures?) { self.parent = parent self.context = context self.sticky = sticky diff --git a/Sources/Featurevisor/Emitter.swift b/Sources/Featurevisor/Emitter.swift index 9443fba..f4adf1e 100644 --- a/Sources/Featurevisor/Emitter.swift +++ b/Sources/Featurevisor/Emitter.swift @@ -14,7 +14,7 @@ public struct EventPayload: Sendable { public typealias EventCallback = @Sendable (_ payload: EventPayload) -> Void -public final class Emitter: @unchecked Sendable { +final class Emitter: @unchecked Sendable { private struct Listener { let id: UUID let callback: EventCallback diff --git a/Sources/Featurevisor/Evaluate.swift b/Sources/Featurevisor/Evaluate.swift index f69f92c..5ea9730 100644 --- a/Sources/Featurevisor/Evaluate.swift +++ b/Sources/Featurevisor/Evaluate.swift @@ -2,8 +2,8 @@ import Foundation public struct EvaluateDependencies: Sendable { public var context: Context - public var logger: Logger - public var modulesManager: ModulesManager + var reportDiagnostic: @Sendable (FeaturevisorDiagnostic) -> Void + var modulesManager: ModulesManager var datafileReader: DatafileReader public var sticky: StickyFeatures? public var defaultVariationValue: VariationValue? @@ -11,7 +11,7 @@ public struct EvaluateDependencies: Sendable { init( context: Context, - logger: Logger, + reportDiagnostic: @escaping @Sendable (FeaturevisorDiagnostic) -> Void, modulesManager: ModulesManager, datafileReader: DatafileReader, sticky: StickyFeatures? = nil, @@ -19,7 +19,7 @@ public struct EvaluateDependencies: Sendable { defaultVariableValue: VariableValue? = nil ) { self.context = context - self.logger = logger + self.reportDiagnostic = reportDiagnostic self.modulesManager = modulesManager self.datafileReader = datafileReader self.sticky = sticky @@ -78,7 +78,7 @@ func evaluate(_ options: EvaluateOptions) -> Evaluation { let context = options.dependencies.context let datafileReader = options.dependencies.datafileReader let modulesManager = options.dependencies.modulesManager - let logger = options.dependencies.logger + let reportDiagnostic = options.dependencies.reportDiagnostic do { if type != .flag { @@ -111,7 +111,12 @@ func evaluate(_ options: EvaluateOptions) -> Evaluation { } } - logger.debug("feature is disabled", details: ["featureKey": featureKey]) + reportDiagnostic(FeaturevisorDiagnostic( + level: .debug, + code: "disabled", + message: "Feature is disabled", + details: ["featureKey": .string(featureKey)] + )) return disabled } } @@ -141,20 +146,61 @@ func evaluate(_ options: EvaluateOptions) -> Evaluation { } guard let feature = datafileReader.getFeature(featureKey) else { + reportDiagnostic(FeaturevisorDiagnostic( + level: .warn, + code: "feature_not_found", + message: "Feature not found", + details: ["featureKey": .string(featureKey)] + )) return Evaluation(type: type, featureKey: featureKey, reason: .featureNotFound) } + if type == .flag, feature.deprecated == true { + reportDiagnostic(FeaturevisorDiagnostic( + level: .warn, + code: "deprecated_feature", + message: "Feature is deprecated", + details: ["featureKey": .string(featureKey)] + )) + } + var variableSchema: ResolvedVariableSchema? if let variableKey { variableSchema = feature.variablesSchema?[variableKey] if variableSchema == nil { + reportDiagnostic(FeaturevisorDiagnostic( + level: .warn, + code: "variable_not_found", + message: "Variable schema not found", + details: [ + "featureKey": .string(featureKey), + "variableKey": .string(variableKey), + ] + )) var out = Evaluation(type: type, featureKey: featureKey, reason: .variableNotFound) out.variableKey = variableKey return out } + if variableSchema?.deprecated == true { + reportDiagnostic(FeaturevisorDiagnostic( + level: .warn, + code: "deprecated_variable", + message: "Variable is deprecated", + details: [ + "featureKey": .string(featureKey), + "variableKey": .string(variableKey), + ] + )) + } } if type == .variation, (feature.variations ?? []).isEmpty { + reportDiagnostic(FeaturevisorDiagnostic( + level: .warn, + code: "no_variations", + message: "No variations", + details: ["featureKey": .string(featureKey)] + )) return Evaluation(type: type, featureKey: featureKey, reason: .noVariations) } diff --git a/Sources/Featurevisor/Instance.swift b/Sources/Featurevisor/Instance.swift index f9a6eb7..6072ce1 100644 --- a/Sources/Featurevisor/Instance.swift +++ b/Sources/Featurevisor/Instance.swift @@ -4,7 +4,6 @@ public struct FeaturevisorOptions: Sendable { public var datafile: DatafileContent? public var context: Context public var logLevel: LogLevel - public var logger: Logger? public var onDiagnostic: FeaturevisorDiagnosticHandler? public var sticky: StickyFeatures? public var modules: [FeaturevisorModule] @@ -12,8 +11,7 @@ public struct FeaturevisorOptions: Sendable { public init( datafile: DatafileContent? = nil, context: Context = [:], - logLevel: LogLevel = Logger.defaultLevel, - logger: Logger? = nil, + logLevel: LogLevel = .info, onDiagnostic: FeaturevisorDiagnosticHandler? = nil, sticky: StickyFeatures? = nil, modules: [FeaturevisorModule] = [] @@ -21,7 +19,6 @@ public struct FeaturevisorOptions: Sendable { self.datafile = datafile self.context = context self.logLevel = logLevel - self.logger = logger self.onDiagnostic = onDiagnostic self.sticky = sticky self.modules = modules @@ -37,7 +34,7 @@ private struct ModuleDiagnosticSubscription: Sendable { let logLevel: LogLevel } -public final class FeaturevisorInstance: @unchecked Sendable { +public final class Featurevisor: @unchecked Sendable { private var context: Context private let logger: Logger private var logLevel: LogLevel @@ -51,9 +48,9 @@ public final class FeaturevisorInstance: @unchecked Sendable { private let emitter: Emitter private var closed = false - public init(options: FeaturevisorOptions) { + fileprivate init(options: FeaturevisorOptions) { self.context = options.context - self.logger = options.logger ?? createLogger(level: options.logLevel) + self.logger = createLogger(level: options.logLevel) self.logLevel = options.logLevel self.onDiagnostic = options.onDiagnostic self.sticky = options.sticky @@ -78,6 +75,18 @@ public final class FeaturevisorInstance: @unchecked Sendable { } ) + self.logger.setHandler { [weak self] level, message, details in + var code = details["reason"] ?? message + if message == "feature is deprecated" { code = "deprecated_feature" } + if message == "variable is deprecated" { code = "deprecated_variable" } + self?.reportDiagnostic(FeaturevisorDiagnostic( + level: level, + code: code, + message: message, + details: details.mapValues { .string($0) } + )) + } + if let datafile = options.datafile { setDatafile(datafile, replace: true) } @@ -136,7 +145,12 @@ public final class FeaturevisorInstance: @unchecked Sendable { } public func getRevision() -> String { datafileReader.getRevision() } + public func getSchemaVersion() -> String { datafileReader.getSchemaVersion() } + public func getSegment(_ segmentKey: SegmentKey) -> Segment? { datafileReader.getSegment(segmentKey) } public func getFeature(_ featureKey: String) -> Feature? { datafileReader.getFeature(featureKey) } + public func getFeatureKeys() -> [FeatureKey] { datafileReader.getFeatureKeys() } + public func getVariableKeys(_ featureKey: FeatureKey) -> [String] { datafileReader.getVariableKeys(featureKey) } + public func hasVariations(_ featureKey: FeatureKey) -> Bool { datafileReader.hasVariations(featureKey) } @discardableResult public func addModule(_ module: FeaturevisorModule) -> FeaturevisorUnsubscribe? { @@ -183,13 +197,7 @@ public final class FeaturevisorInstance: @unchecked Sendable { if let moduleName = diagnostic.moduleName { details["moduleName"] = moduleName } if let originalError = diagnostic.originalError { details["originalError"] = originalError } - switch diagnostic.level { - case .debug: logger.debug(diagnostic.message, details: details) - case .info: logger.info(diagnostic.message, details: details) - case .warn: logger.warn(diagnostic.message, details: details) - case .error: logger.error(diagnostic.message, details: details) - case .fatal: logger.fatal(diagnostic.message, details: details) - } + Logger.writeToConsole(diagnostic.level, diagnostic.message, details) } } @@ -270,7 +278,9 @@ public final class FeaturevisorInstance: @unchecked Sendable { private func dependencies(_ context: Context, options: OverrideOptions = OverrideOptions()) -> EvaluateDependencies { EvaluateDependencies( context: getContext(context), - logger: logger, + reportDiagnostic: { [weak self] diagnostic in + self?.reportDiagnostic(diagnostic) + }, modulesManager: modulesManager, datafileReader: datafileReader, sticky: options.sticky ?? sticky, @@ -366,6 +376,6 @@ private func mergeStoredDatafile(existing: DatafileContent, incoming: DatafileCo ) } -public func createInstance(_ options: FeaturevisorOptions = FeaturevisorOptions()) -> FeaturevisorInstance { - FeaturevisorInstance(options: options) +public func createFeaturevisor(_ options: FeaturevisorOptions = FeaturevisorOptions()) -> Featurevisor { + Featurevisor(options: options) } diff --git a/Sources/Featurevisor/Logger.swift b/Sources/Featurevisor/Logger.swift index 2880c41..45c72d9 100644 --- a/Sources/Featurevisor/Logger.swift +++ b/Sources/Featurevisor/Logger.swift @@ -8,23 +8,32 @@ public enum LogLevel: Int, Codable, CaseIterable, Sendable { case fatal = 50 } -public typealias LogHandler = @Sendable (_ level: LogLevel, _ message: String, _ details: [String: String]) -> Void +typealias LogHandler = @Sendable (_ level: LogLevel, _ message: String, _ details: [String: String]) -> Void -public final class Logger: @unchecked Sendable { - public static let defaultLevel: LogLevel = .info +final class Logger: @unchecked Sendable { + static let defaultLevel: LogLevel = .info private var level: LogLevel - private let handler: LogHandler? + private var handler: LogHandler? - public init(level: LogLevel = Logger.defaultLevel, handler: LogHandler? = nil) { + init(level: LogLevel = Logger.defaultLevel, handler: LogHandler? = nil) { self.level = level self.handler = handler } - public func setLevel(_ level: LogLevel) { + func setLevel(_ level: LogLevel) { self.level = level } + func setHandler(_ handler: LogHandler?) { + self.handler = handler + } + + static func writeToConsole(_ level: LogLevel, _ message: String, _ details: [String: String]) { + let serialized = details.isEmpty ? "" : " \(details)" + FileHandle.standardError.write(Data("[Featurevisor] \(message)\(serialized)\n".utf8)) + } + private func shouldLog(_ incoming: LogLevel) -> Bool { incoming.rawValue >= level.rawValue } @@ -35,17 +44,16 @@ public final class Logger: @unchecked Sendable { handler(incoming, message, details) return } - let serialized = details.isEmpty ? "" : " \(details)" - FileHandle.standardError.write(Data("[\(incoming)] \(message)\(serialized)\n".utf8)) + Logger.writeToConsole(incoming, message, details) } - public func debug(_ message: String, details: [String: String] = [:]) { emit(.debug, message, details) } - public func info(_ message: String, details: [String: String] = [:]) { emit(.info, message, details) } - public func warn(_ message: String, details: [String: String] = [:]) { emit(.warn, message, details) } - public func error(_ message: String, details: [String: String] = [:]) { emit(.error, message, details) } - public func fatal(_ message: String, details: [String: String] = [:]) { emit(.fatal, message, details) } + func debug(_ message: String, details: [String: String] = [:]) { emit(.debug, message, details) } + func info(_ message: String, details: [String: String] = [:]) { emit(.info, message, details) } + func warn(_ message: String, details: [String: String] = [:]) { emit(.warn, message, details) } + func error(_ message: String, details: [String: String] = [:]) { emit(.error, message, details) } + func fatal(_ message: String, details: [String: String] = [:]) { emit(.fatal, message, details) } } -public func createLogger(level: LogLevel = Logger.defaultLevel, handler: LogHandler? = nil) -> Logger { +func createLogger(level: LogLevel = Logger.defaultLevel, handler: LogHandler? = nil) -> Logger { Logger(level: level, handler: handler) } diff --git a/Sources/Featurevisor/Modules.swift b/Sources/Featurevisor/Modules.swift index ee4eac4..2445ac8 100644 --- a/Sources/Featurevisor/Modules.swift +++ b/Sources/Featurevisor/Modules.swift @@ -83,7 +83,7 @@ public struct FeaturevisorModule: Sendable { } } -public final class ModulesManager: @unchecked Sendable { +final class ModulesManager: @unchecked Sendable { private var modules: [FeaturevisorModule] = [] private let reportDiagnostic: @Sendable (FeaturevisorDiagnostic, FeaturevisorModule?) -> Void private let getModuleApi: @Sendable (FeaturevisorModule) -> FeaturevisorModuleApi diff --git a/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift b/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift index 41d76b9..17ecb3b 100644 --- a/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/AssessDistributionCommand.swift @@ -48,7 +48,7 @@ struct AssessDistributionCommand { return 1 } - let sdk = createInstance(FeaturevisorOptions(datafile: datafile, logLevel: CLIHelpers.loggerLevel(options))) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: datafile, logLevel: CLIHelpers.loggerLevel(options))) let baseContext = CLIHelpers.parseContext(options.context) var flagCounts: [String: Int] = ["enabled": 0, "disabled": 0] diff --git a/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift b/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift index f35f9ea..ec4a2ab 100644 --- a/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/BenchmarkCommand.swift @@ -88,7 +88,7 @@ struct BenchmarkCommand { return 1 } - let sdk = createInstance(FeaturevisorOptions(datafile: datafile, logLevel: CLIHelpers.loggerLevel(options))) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: datafile, logLevel: CLIHelpers.loggerLevel(options))) print("\nBenchmark Featurevisor feature") print(" Feature: \(options.feature)") diff --git a/Sources/FeaturevisorCLI/Commands/TestCommand.swift b/Sources/FeaturevisorCLI/Commands/TestCommand.swift index 09bfb78..5f5604d 100644 --- a/Sources/FeaturevisorCLI/Commands/TestCommand.swift +++ b/Sources/FeaturevisorCLI/Commands/TestCommand.swift @@ -160,7 +160,7 @@ struct TestCommand { return cache } - private func sdkForAssertion(datafile: DatafileContent, assertion: [String: Any], options: CLIOptions) -> FeaturevisorInstance { + private func sdkForAssertion(datafile: DatafileContent, assertion: [String: Any], options: CLIOptions) -> Featurevisor { let sticky = CLIHelpers.anyToSticky(assertion["sticky"]) let forcedAt = CLIHelpers.doubleValue(assertion["at"]) let module = FeaturevisorModule(name: "test-module", bucketValue: { options in @@ -170,7 +170,7 @@ struct TestCommand { return options.bucketValue }) - return createInstance( + return createFeaturevisor( FeaturevisorOptions( datafile: datafile, logLevel: CLIHelpers.loggerLevel(options), diff --git a/Tests/FeaturevisorTests/ChildTests.swift b/Tests/FeaturevisorTests/ChildTests.swift index 64e5c7b..98cf62f 100644 --- a/Tests/FeaturevisorTests/ChildTests.swift +++ b/Tests/FeaturevisorTests/ChildTests.swift @@ -3,7 +3,7 @@ import XCTest final class ChildTests: XCTestCase { func testChildContextAndSticky() { - let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile(), context: ["app": .string("ios")])) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: TestFixtures.basicDatafile(), context: ["app": .string("ios")])) let child = sdk.spawn(["userId": .string("123")]) XCTAssertEqual(child.getContext()["app"], .string("ios")) @@ -14,7 +14,7 @@ final class ChildTests: XCTestCase { } func testChildDelegatesParentEventsForDatafileSet() { - let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) let child = sdk.spawn() let called = ConcurrencyBox(false) diff --git a/Tests/FeaturevisorTests/FeaturevisorTests.swift b/Tests/FeaturevisorTests/FeaturevisorTests.swift index cbac1b9..7d7e1d6 100644 --- a/Tests/FeaturevisorTests/FeaturevisorTests.swift +++ b/Tests/FeaturevisorTests/FeaturevisorTests.swift @@ -49,7 +49,7 @@ final class FeaturevisorTests: XCTestCase { ] ) - let sdk = createInstance(FeaturevisorOptions(datafile: datafile)) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: datafile)) XCTAssertTrue(sdk.isEnabled("test", ["userId": .string("123")])) } @@ -97,7 +97,7 @@ final class FeaturevisorTests: XCTestCase { ] ) - let sdk = createInstance(FeaturevisorOptions(datafile: datafile)) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: datafile)) XCTAssertEqual(sdk.getVariableString("test", "color", ["userId": .string("123")]), "red") } } diff --git a/Tests/FeaturevisorTests/IndexTests.swift b/Tests/FeaturevisorTests/IndexTests.swift index c5091d9..9144346 100644 --- a/Tests/FeaturevisorTests/IndexTests.swift +++ b/Tests/FeaturevisorTests/IndexTests.swift @@ -2,8 +2,8 @@ import XCTest @testable import Featurevisor final class IndexTests: XCTestCase { - func testCreateInstanceExists() { - let instance = createInstance() + func testCreateFeaturevisorExists() { + let instance = createFeaturevisor() XCTAssertNotNil(instance) } } diff --git a/Tests/FeaturevisorTests/InstanceTests.swift b/Tests/FeaturevisorTests/InstanceTests.swift index 6e110bc..3cd9868 100644 --- a/Tests/FeaturevisorTests/InstanceTests.swift +++ b/Tests/FeaturevisorTests/InstanceTests.swift @@ -3,9 +3,13 @@ import XCTest final class InstanceTests: XCTestCase { func testLifecycleAndEvaluations() { - let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) XCTAssertEqual(sdk.getRevision(), "1") + XCTAssertEqual(sdk.getSchemaVersion(), "2") + XCTAssertEqual(sdk.getFeatureKeys(), ["test"]) + XCTAssertTrue(sdk.hasVariations("test")) + XCTAssertEqual(Set(sdk.getVariableKeys("test")), Set(["color", "count"])) XCTAssertTrue(sdk.isEnabled("test", ["userId": .string("123")])) XCTAssertEqual(sdk.getVariation("test", ["userId": .string("123")]), "control") XCTAssertEqual(sdk.getVariableString("test", "color", ["userId": .string("123")]), "blue") @@ -16,7 +20,7 @@ final class InstanceTests: XCTestCase { } func testSetContextAndStickyEvents() { - let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) let contextEvent = ConcurrencyBox(false) let stickyEvent = ConcurrencyBox(false) @@ -38,7 +42,7 @@ final class InstanceTests: XCTestCase { func testLifecycleMutationsReportDiagnostics() { let diagnostics = ConcurrencyBox<[String]>([]) - let sdk = createInstance(FeaturevisorOptions( + let sdk = createFeaturevisor(FeaturevisorOptions( logLevel: .debug, onDiagnostic: { diagnostic in diagnostics.value.append(diagnostic.code) @@ -54,8 +58,22 @@ final class InstanceTests: XCTestCase { XCTAssertTrue(diagnostics.value.contains("context_set")) } + func testEvaluationWarningsUseDiagnostics() { + var datafile = TestFixtures.basicDatafile() + datafile.features["test"]?.deprecated = true + let diagnostics = ConcurrencyBox<[String]>([]) + let sdk = createFeaturevisor(FeaturevisorOptions( + datafile: datafile, + onDiagnostic: { diagnostics.value.append($0.code) } + )) + + _ = sdk.isEnabled("test", ["userId": .string("123")]) + + XCTAssertTrue(diagnostics.value.contains("deprecated_feature")) + } + func testSetDatafileMergesByDefaultAndReplacesWhenRequested() { - let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) let secondDatafile = DatafileContent( schemaVersion: "2", @@ -90,7 +108,7 @@ final class InstanceTests: XCTestCase { } func testDatafileSetEventIncludesReplaced() { - let sdk = createInstance(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: TestFixtures.basicDatafile())) let replaced = ConcurrencyBox(nil) let unsubscribe = sdk.on(.datafileSet) { payload in diff --git a/Tests/FeaturevisorTests/ModulesTests.swift b/Tests/FeaturevisorTests/ModulesTests.swift index 3d23c41..0da570e 100644 --- a/Tests/FeaturevisorTests/ModulesTests.swift +++ b/Tests/FeaturevisorTests/ModulesTests.swift @@ -30,7 +30,7 @@ final class ModulesTests: XCTestCase { } ) - let sdk = createInstance(FeaturevisorOptions( + let sdk = createFeaturevisor(FeaturevisorOptions( datafile: TestFixtures.basicDatafile(), logLevel: .info, onDiagnostic: { diagnostic in @@ -57,7 +57,7 @@ final class ModulesTests: XCTestCase { func testRemovedModulesAreClosed() { let closed = ConcurrencyBox<[String]>([]) - let sdk = createInstance(FeaturevisorOptions(logLevel: .fatal)) + let sdk = createFeaturevisor(FeaturevisorOptions(logLevel: .fatal)) let unsubscribe = sdk.addModule(FeaturevisorModule( name: "dynamic", @@ -86,7 +86,7 @@ final class ModulesTests: XCTestCase { let errorEventCodes = ConcurrencyBox<[String]>([]) let closed = ConcurrencyBox<[String]>([]) - let sdk = createInstance(FeaturevisorOptions( + let sdk = createFeaturevisor(FeaturevisorOptions( logLevel: .info, onDiagnostic: { diagnostic in diagnostics.value.append(diagnostic) @@ -127,7 +127,7 @@ final class ModulesTests: XCTestCase { func testModuleUnsubscribeReportsCloseErrors() { let diagnostics = ConcurrencyBox<[FeaturevisorDiagnostic]>([]) - let sdk = createInstance(FeaturevisorOptions( + let sdk = createFeaturevisor(FeaturevisorOptions( logLevel: .info, onDiagnostic: { diagnostic in diagnostics.value.append(diagnostic) @@ -193,7 +193,7 @@ final class ModulesTests: XCTestCase { ] ) - let sdk = createInstance(FeaturevisorOptions(datafile: datafile, modules: [module])) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: datafile, modules: [module])) let evaluation = sdk.evaluateFlag("exp") XCTAssertEqual(bucketKeySeen.value, "from-before.exp") diff --git a/Tests/FeaturevisorTests/ParityBehaviorTests.swift b/Tests/FeaturevisorTests/ParityBehaviorTests.swift index e0e0342..6778465 100644 --- a/Tests/FeaturevisorTests/ParityBehaviorTests.swift +++ b/Tests/FeaturevisorTests/ParityBehaviorTests.swift @@ -44,7 +44,7 @@ final class ParityBehaviorTests: XCTestCase { ] ) - let sdk = createInstance(FeaturevisorOptions(datafile: datafile)) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: datafile)) XCTAssertTrue(sdk.isEnabled("dependent", ["userId": .string("1")])) } @@ -100,7 +100,7 @@ final class ParityBehaviorTests: XCTestCase { ] ) - let sdk = createInstance(FeaturevisorOptions(datafile: datafile)) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: datafile)) XCTAssertEqual(sdk.getVariable("feature", "title", ["userId": .string("1"), "country": .string("de")]), .string("rule-override")) XCTAssertEqual(sdk.getVariable("feature", "title", ["userId": .string("1"), "country": .string("nl")]), .string("variation-override")) XCTAssertEqual(sdk.getVariable("feature", "title", ["userId": .string("1"), "country": .string("fr")]), .string("variation")) @@ -129,7 +129,7 @@ final class ParityBehaviorTests: XCTestCase { ) let module = FeaturevisorModule(name: "force-bucket", bucketValue: { _ in 50_000 }) - let sdk = createInstance(FeaturevisorOptions(datafile: datafile, modules: [module])) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: datafile, modules: [module])) let evaluation = sdk.evaluateFlag("exp", context: ["userId": .string("1")]) XCTAssertEqual(evaluation.reason, .outOfRange) @@ -161,7 +161,7 @@ final class ParityBehaviorTests: XCTestCase { ] ) - let sdk = createInstance(FeaturevisorOptions(datafile: datafile)) + let sdk = createFeaturevisor(FeaturevisorOptions(datafile: datafile)) XCTAssertEqual(sdk.getVariation("disabled", ["userId": .string("1")]), "off") XCTAssertEqual(sdk.getVariable("disabled", "a", ["userId": .string("1")]), .string("A-default")) XCTAssertEqual(sdk.getVariable("disabled", "b", ["userId": .string("1")]), .string("B-disabled")) From e84adfdc8af070b65bd69cf56c95eb692828b29a Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 20:39:35 +0200 Subject: [PATCH 11/13] alignment --- README.md | 6 +++++- Sources/Featurevisor/Instance.swift | 11 ++++++++--- Tests/FeaturevisorTests/ModulesTests.swift | 3 ++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b8a6859..f179918 100644 --- a/README.md +++ b/README.md @@ -460,12 +460,16 @@ unsubscribe() ```swift let unsubscribe = f.on(.error) { payload in - print(payload.params["message"] ?? "") + if case .object(let diagnostic)? = payload.params["diagnostic"] { + print(diagnostic["message"] ?? "") + } } unsubscribe() ``` +The `error` event is emitted for diagnostics whose level is `error`. + ## Evaluation details If you need evaluation metadata, use: diff --git a/Sources/Featurevisor/Instance.swift b/Sources/Featurevisor/Instance.swift index 6072ce1..d738226 100644 --- a/Sources/Featurevisor/Instance.swift +++ b/Sources/Featurevisor/Instance.swift @@ -202,11 +202,16 @@ public final class Featurevisor: @unchecked Sendable { } if diagnostic.level == .error { - emitter.trigger(.error, payload: EventPayload([ + var normalized: [String: AnyValue] = [ + "level": .string(String(describing: diagnostic.level)), "code": .string(diagnostic.code), "message": .string(diagnostic.message), - "level": .string("error"), - ])) + "details": .object(diagnostic.details), + ] + if let module = diagnostic.module { normalized["module"] = .string(module) } + if let moduleName = diagnostic.moduleName { normalized["moduleName"] = .string(moduleName) } + if let originalError = diagnostic.originalError { normalized["originalError"] = .string(originalError) } + emitter.trigger(.error, payload: EventPayload(["diagnostic": .object(normalized)])) } } diff --git a/Tests/FeaturevisorTests/ModulesTests.swift b/Tests/FeaturevisorTests/ModulesTests.swift index 0da570e..cb8f9ea 100644 --- a/Tests/FeaturevisorTests/ModulesTests.swift +++ b/Tests/FeaturevisorTests/ModulesTests.swift @@ -94,7 +94,8 @@ final class ModulesTests: XCTestCase { )) sdk.on(.error) { payload in - if case .string(let code)? = payload.params["code"] { + if case .object(let diagnostic)? = payload.params["diagnostic"], + case .string(let code)? = diagnostic["code"] { errorEventCodes.value.append(code) } } From 5a8e3519828167427c62638d4ec128ab3025ea5f Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 23:53:33 +0200 Subject: [PATCH 12/13] updates --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 878986e..3624c3a 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ test: test-example-1: swift test - swift run featurevisor test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures + swift run featurevisor test --projectDirectoryPath=$(abspath ../featurevisor/examples/example-1) --onlyFailures clean: swift package clean From 9a237182f1cdc2798b79b0333eb68bb1b71448e6 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Tue, 14 Jul 2026 00:13:26 +0200 Subject: [PATCH 13/13] workflow updates --- .github/workflows/checks.yml | 40 +++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index b0e4531..48ac7fc 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,18 +1,31 @@ name: Checks on: - - push + push: + branches: + - main + pull_request: + +permissions: + contents: read + +concurrency: + group: checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - build: + checks: runs-on: macos-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v3 + - name: Check out repository + uses: actions/checkout@v7 - - uses: swift-actions/setup-swift@v2 + - name: Set up Swift + uses: swift-actions/setup-swift@v2.4.0 with: - swift-version: "6.1.2" + swift-version: "6.2.1" - name: Build run: swift build @@ -20,17 +33,20 @@ jobs: - name: Test run: swift test - - uses: actions/setup-node@v4 + - name: Set up Node.js + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" + package-manager-cache: false - - name: Setup Featurevisor example-1 project + - name: Set up Featurevisor example-1 project run: | mkdir example-1 - (cd example-1 && npx @featurevisor/cli@2.x init --example=1) - (cd example-1 && npm install) - (cd example-1 && npx featurevisor build) - (cd example-1 && npx featurevisor test) + cd example-1 + npx --yes @featurevisor/cli@3.1.0 init --example=1 + npm install + npx featurevisor build + npx featurevisor test --onlyFailures - name: Run Featurevisor project tests against Swift SDK - run: swift run featurevisor test --projectDirectoryPath=./example-1 + run: swift run featurevisor test --projectDirectoryPath=./example-1 --onlyFailures