diff --git a/CHANGELOG.md b/CHANGELOG.md index 66d5645a..6937e6bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to the LaunchDarkly iOS SDK will be documented in this file. ### Multiple Environment clients Version 4.0.0 does not support multiple environments. If you use version `2.14.0` or later and set `LDConfig`'s `secondaryMobileKeys` you will not be able to migrate to version `4.0.0`. Multiple Environments will be added in a future release to the Swift SDK. +## [4.3.0] - 2019-12-3 +### Added +- Implemented `variationDetail` which returns an Evaluation Reason giving developers greater insight into why a value was returned. +- Added support for the latest Experimentation features allowing increased value from A/B/n testing. The `track` method now supports an additional `metricValue` parameter. + ## [4.2.1] - 2019-11-15 ### Changed - Updated to `ios-eventsource` version `4.0.3`. This appends a platform name to bundle identifiers. (Thanks, [cswelin](https://github.com/launchdarkly/ios-eventsource/pull/28)!) diff --git a/LaunchDarkly.podspec b/LaunchDarkly.podspec index ab6f965c..0af30643 100644 --- a/LaunchDarkly.podspec +++ b/LaunchDarkly.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |ld| ld.name = "LaunchDarkly" - ld.version = "4.2.1" + ld.version = "4.3.0" ld.summary = "iOS SDK for LaunchDarkly" ld.description = <<-DESC @@ -25,7 +25,7 @@ Pod::Spec.new do |ld| ld.tvos.deployment_target = "9.0" ld.osx.deployment_target = "10.10" - ld.source = { :git => "https://github.com/launchdarkly/ios-client-sdk.git", :tag => '4.2.1'} + ld.source = { :git => "https://github.com/launchdarkly/ios-client-sdk.git", :tag => '4.3.0'} ld.source_files = "LaunchDarkly/LaunchDarkly/**/*.{h,m,swift}" diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index 16b07bee..5d4b29bd 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -300,6 +300,15 @@ 83FEF8DF1F2667E4001CF12C /* EventReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83FEF8DE1F2667E4001CF12C /* EventReporter.swift */; }; C408884723033B3600420721 /* ConnectionInformationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C408884623033B3600420721 /* ConnectionInformationStore.swift */; }; C408884923033B7500420721 /* ConnectionInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C408884823033B7500420721 /* ConnectionInformation.swift */; }; + C43C37E1236BA050003C1624 /* EvaluationDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43C37E0236BA050003C1624 /* EvaluationDetail.swift */; }; + C43C37E32370DC7C003C1624 /* DeprecatedCacheModelV6.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43C37E22370DC7C003C1624 /* DeprecatedCacheModelV6.swift */; }; + C43C37E5238C8FCD003C1624 /* DeprecatedCacheModelV6Spec.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43C37E4238C8FCD003C1624 /* DeprecatedCacheModelV6Spec.swift */; }; + C43C37E6238DF22B003C1624 /* EvaluationDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43C37E0236BA050003C1624 /* EvaluationDetail.swift */; }; + C43C37E7238DF22C003C1624 /* EvaluationDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43C37E0236BA050003C1624 /* EvaluationDetail.swift */; }; + C43C37E8238DF22D003C1624 /* EvaluationDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43C37E0236BA050003C1624 /* EvaluationDetail.swift */; }; + C43C37EA238DF238003C1624 /* DeprecatedCacheModelV6.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43C37E22370DC7C003C1624 /* DeprecatedCacheModelV6.swift */; }; + C43C37EB238DF238003C1624 /* DeprecatedCacheModelV6.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43C37E22370DC7C003C1624 /* DeprecatedCacheModelV6.swift */; }; + C43C37EC238DF239003C1624 /* DeprecatedCacheModelV6.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43C37E22370DC7C003C1624 /* DeprecatedCacheModelV6.swift */; }; C443A40323145FB700145710 /* ConnectionInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C408884823033B7500420721 /* ConnectionInformation.swift */; }; C443A40423145FBE00145710 /* ConnectionInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C408884823033B7500420721 /* ConnectionInformation.swift */; }; C443A40523145FBF00145710 /* ConnectionInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C408884823033B7500420721 /* ConnectionInformation.swift */; }; @@ -314,6 +323,10 @@ C443A41023186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */; }; C443A41123186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */; }; C443A41223186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */; }; + C4A6B65F23949AA20028C074 /* ObjCEvaluationDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A6B65E23949AA20028C074 /* ObjCEvaluationDetail.swift */; }; + C4A6B6602395207D0028C074 /* ObjCEvaluationDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A6B65E23949AA20028C074 /* ObjCEvaluationDetail.swift */; }; + C4A6B6612395207E0028C074 /* ObjCEvaluationDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A6B65E23949AA20028C074 /* ObjCEvaluationDetail.swift */; }; + C4A6B6622395207E0028C074 /* ObjCEvaluationDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A6B65E23949AA20028C074 /* ObjCEvaluationDetail.swift */; }; E48F5215B96AE48D10185962 /* Pods_LaunchDarkly_tvOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB95B7FEBDC1E23F47304829 /* Pods_LaunchDarkly_tvOS.framework */; }; /* End PBXBuildFile section */ @@ -458,8 +471,12 @@ B0A56C29F8C0E59F338F9A07 /* Pods-LaunchDarkly_tvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LaunchDarkly_tvOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-LaunchDarkly_tvOS/Pods-LaunchDarkly_tvOS.release.xcconfig"; sourceTree = ""; }; C408884623033B3600420721 /* ConnectionInformationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionInformationStore.swift; sourceTree = ""; }; C408884823033B7500420721 /* ConnectionInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionInformation.swift; sourceTree = ""; }; + C43C37E0236BA050003C1624 /* EvaluationDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationDetail.swift; sourceTree = ""; }; + C43C37E22370DC7C003C1624 /* DeprecatedCacheModelV6.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedCacheModelV6.swift; sourceTree = ""; }; + C43C37E4238C8FCD003C1624 /* DeprecatedCacheModelV6Spec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedCacheModelV6Spec.swift; sourceTree = ""; }; C443A4092315AA4D00145710 /* NetworkReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkReporter.swift; sourceTree = ""; }; C443A40E23186A4F00145710 /* ConnectionModeChangeObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionModeChangeObserver.swift; sourceTree = ""; }; + C4A6B65E23949AA20028C074 /* ObjCEvaluationDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjCEvaluationDetail.swift; sourceTree = ""; }; D58D143F8FD161584B3FF3AF /* Pods-LaunchDarklyTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LaunchDarklyTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-LaunchDarklyTests/Pods-LaunchDarklyTests.release.xcconfig"; sourceTree = ""; }; D6840A437019F1CB72997480 /* Pods-LaunchDarkly_iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LaunchDarkly_iOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-LaunchDarkly_iOS/Pods-LaunchDarkly_iOS.release.xcconfig"; sourceTree = ""; }; D8204934C417AFCE089F38BC /* Pods-LaunchDarklyTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LaunchDarklyTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-LaunchDarklyTests/Pods-LaunchDarklyTests.debug.xcconfig"; sourceTree = ""; }; @@ -642,6 +659,7 @@ 83D1522F224D92D30054B6D4 /* DeprecatedCacheModelV4.swift */, 83D1522A224D91B90054B6D4 /* DeprecatedCacheModelV3.swift */, 832D68A6224A4668005F052A /* DeprecatedCacheModelV2.swift */, + C43C37E22370DC7C003C1624 /* DeprecatedCacheModelV6.swift */, ); path = Cache; sourceTree = ""; @@ -656,6 +674,7 @@ 83D15238225455D40054B6D4 /* DeprecatedCacheModelV4Spec.swift */, 83D15236225400CE0054B6D4 /* DeprecatedCacheModelV3Spec.swift */, 83D15234225299890054B6D4 /* DeprecatedCacheModelV2Spec.swift */, + C43C37E4238C8FCD003C1624 /* DeprecatedCacheModelV6Spec.swift */, ); path = Cache; sourceTree = ""; @@ -741,6 +760,7 @@ 835E1D461F68B3EC00184DB4 /* ObjcLDFlagValue.swift */, 835E1D421F685AC900184DB4 /* ObjcLDChangedFlag.swift */, 831D8B6C1F6B1F7B00ED65E8 /* ObjcLDVariationValue.swift */, + C4A6B65E23949AA20028C074 /* ObjCEvaluationDetail.swift */, ); path = ObjectiveC; sourceTree = ""; @@ -815,6 +835,7 @@ 83EBCB9F20D9A143003A7142 /* FlagChange */, 83EBCBA120D9A1BA003A7142 /* FlagRequestTracking */, 83EBCBA020D9A168003A7142 /* FlagValue */, + C43C37E0236BA050003C1624 /* EvaluationDetail.swift */, ); path = FeatureFlag; sourceTree = ""; @@ -1395,6 +1416,7 @@ 831188692113AE5900D77CB5 /* ObjcLDConfig.swift in Sources */, 831188602113AE3400D77CB5 /* Dictionary.swift in Sources */, 8311886C2113AE6400D77CB5 /* ObjcLDChangedFlag.swift in Sources */, + C43C37E8238DF22D003C1624 /* EvaluationDetail.swift in Sources */, 83906A7921190B4000D7D3C5 /* FlagValueCounter.swift in Sources */, 8311884C2113ADDE00D77CB5 /* FlagChangeObserver.swift in Sources */, C443A41223186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */, @@ -1412,6 +1434,7 @@ 831188492113ADD400D77CB5 /* LDFlagBaseTypeConvertible.swift in Sources */, 8311885C2113AE2200D77CB5 /* HTTPHeaders.swift in Sources */, 832D68AA224A4668005F052A /* DeprecatedCacheModelV2.swift in Sources */, + C43C37EC238DF239003C1624 /* DeprecatedCacheModelV6.swift in Sources */, 831188562113AE0800D77CB5 /* FlagSynchronizer.swift in Sources */, 8311884A2113ADD700D77CB5 /* FeatureFlag.swift in Sources */, 8311885A2113AE1500D77CB5 /* Log.swift in Sources */, @@ -1433,6 +1456,7 @@ 831188472113ADCD00D77CB5 /* LDFlagValue.swift in Sources */, 831188442113ADC200D77CB5 /* LDConfig.swift in Sources */, 83906A7721190B1900D7D3C5 /* FlagRequestTracker.swift in Sources */, + C4A6B6622395207E0028C074 /* ObjCEvaluationDetail.swift in Sources */, 831188622113AE3A00D77CB5 /* Data.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1447,6 +1471,7 @@ 831EF34420655E730001C643 /* LDConfig.swift in Sources */, 831EF34520655E730001C643 /* LDClient.swift in Sources */, 832D689F224A3896005F052A /* DeprecatedCacheModelV5.swift in Sources */, + C4A6B6612395207E0028C074 /* ObjCEvaluationDetail.swift in Sources */, 831EF34620655E730001C643 /* LDUser.swift in Sources */, 831EF34720655E730001C643 /* LDFlagValue.swift in Sources */, 83D15232224D92D30054B6D4 /* DeprecatedCacheModelV4.swift in Sources */, @@ -1485,6 +1510,7 @@ 835E4C54206BDF8D004C6E6C /* EnvironmentReporter.swift in Sources */, 8372668E20D4439600BD1088 /* DateFormatter.swift in Sources */, 8370DF6E225E40B800F84810 /* DeprecatedCache.swift in Sources */, + C43C37E7238DF22C003C1624 /* EvaluationDetail.swift in Sources */, 835F43D320D0309A0070DE51 /* EventTrackingContext.swift in Sources */, 831EF35F20655E730001C643 /* Optional.swift in Sources */, 831EF36020655E730001C643 /* Data.swift in Sources */, @@ -1497,6 +1523,7 @@ 831EF36520655E730001C643 /* Thread.swift in Sources */, 83B1D7C92073F354006D1B1C /* CwlSysctl.swift in Sources */, 831EF36620655E730001C643 /* ObjcLDClient.swift in Sources */, + C43C37EB238DF238003C1624 /* DeprecatedCacheModelV6.swift in Sources */, 831EF36720655E730001C643 /* ObjcLDConfig.swift in Sources */, 831EF36820655E730001C643 /* ObjcLDUser.swift in Sources */, 831EF36920655E730001C643 /* ObjcLDFlagValue.swift in Sources */, @@ -1532,6 +1559,7 @@ 8358F25E1F474E5900ECE1AF /* LDChangedFlag.swift in Sources */, 8354AC6922418C0600CDE602 /* CacheableUserEnvironmentFlags.swift in Sources */, 83D559741FD87CC9002D10C8 /* KeyedValueCache.swift in Sources */, + C43C37E1236BA050003C1624 /* EvaluationDetail.swift in Sources */, 831AAE2C20A9E4F600B46DBA /* Throttler.swift in Sources */, 8354EFE11F26380700C05156 /* LDConfig.swift in Sources */, C443A40F23186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */, @@ -1549,6 +1577,7 @@ 8354AC612241511D00CDE602 /* CacheableEnvironmentFlags.swift in Sources */, 83FEF8DF1F2667E4001CF12C /* EventReporter.swift in Sources */, 832D68A7224A4668005F052A /* DeprecatedCacheModelV2.swift in Sources */, + C43C37E32370DC7C003C1624 /* DeprecatedCacheModelV6.swift in Sources */, 831D2AAF2061AAA000B4AC3C /* Thread.swift in Sources */, 83B9A082204F6022000C3F17 /* FlagsUnchangedObserver.swift in Sources */, 8354EFE01F26380700C05156 /* LDClient.swift in Sources */, @@ -1570,6 +1599,7 @@ 838F96781FBA504A009CFC45 /* ClientServiceFactory.swift in Sources */, 83EBCBAC20D9C6A6003A7142 /* FlagCounter.swift in Sources */, 83DDBEFA1FA24AFB00E428B6 /* Array.swift in Sources */, + C4A6B65F23949AA20028C074 /* ObjCEvaluationDetail.swift in Sources */, 831D8B6D1F6B1F7B00ED65E8 /* ObjcLDVariationValue.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1590,6 +1620,7 @@ 83DDBF001FA2589900E428B6 /* FlagStoreSpec.swift in Sources */, 83D15237225400CE0054B6D4 /* DeprecatedCacheModelV3Spec.swift in Sources */, 83396BC91F7C3711000E256E /* DarklyServiceSpec.swift in Sources */, + C43C37E5238C8FCD003C1624 /* DeprecatedCacheModelV6Spec.swift in Sources */, 83EF67931F9945E800403126 /* EventSpec.swift in Sources */, 837E38C921E804ED0008A50C /* EnvironmentReporterSpec.swift in Sources */, 83B6E3F1222EFA3800FF2A6A /* ThreadSpec.swift in Sources */, @@ -1656,6 +1687,7 @@ 8354AC6A22418C0600CDE602 /* CacheableUserEnvironmentFlags.swift in Sources */, 83D9EC832062DEAB004D7FA6 /* KeyedValueCache.swift in Sources */, 831AAE2D20A9E4F600B46DBA /* Throttler.swift in Sources */, + C43C37E6238DF22B003C1624 /* EvaluationDetail.swift in Sources */, 83EBCBA420D9A1F3003A7142 /* FlagValueCounter.swift in Sources */, 83D9EC872062DEAB004D7FA6 /* FlagSynchronizer.swift in Sources */, C443A41023186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */, @@ -1673,6 +1705,7 @@ 83D9EC8E2062DEAB004D7FA6 /* HTTPURLResponse.swift in Sources */, 83D9EC8F2062DEAB004D7FA6 /* HTTPURLRequest.swift in Sources */, 832D68A8224A4668005F052A /* DeprecatedCacheModelV2.swift in Sources */, + C43C37EA238DF238003C1624 /* DeprecatedCacheModelV6.swift in Sources */, 83D9EC902062DEAB004D7FA6 /* Dictionary.swift in Sources */, 831425B2206B030100F2EF36 /* EnvironmentReporter.swift in Sources */, 83D9EC912062DEAB004D7FA6 /* Optional.swift in Sources */, @@ -1694,6 +1727,7 @@ 83D9EC9B2062DEAB004D7FA6 /* ObjcLDFlagValue.swift in Sources */, 83EBCBAD20D9C6A6003A7142 /* FlagCounter.swift in Sources */, 83D9EC9C2062DEAB004D7FA6 /* ObjcLDChangedFlag.swift in Sources */, + C4A6B6602395207D0028C074 /* ObjCEvaluationDetail.swift in Sources */, 83D9EC9D2062DEAB004D7FA6 /* ObjcLDVariationValue.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/LaunchDarkly/GeneratedCode/mocks.generated.swift b/LaunchDarkly/GeneratedCode/mocks.generated.swift index b7e8b3f2..30117a88 100644 --- a/LaunchDarkly/GeneratedCode/mocks.generated.swift +++ b/LaunchDarkly/GeneratedCode/mocks.generated.swift @@ -76,7 +76,7 @@ final class DeprecatedCacheMock: DeprecatedCache { // MARK: model var modelSetCount = 0 var setModelCallback: (() -> Void)? - var model: DeprecatedCacheModel = .version5 { + var model: DeprecatedCacheModel = .version6 { didSet { modelSetCount += 1 setModelCallback?() @@ -333,10 +333,10 @@ final class EventReportingMock: EventReporting { var recordFlagEvaluationEventsCallCount = 0 var recordFlagEvaluationEventsCallback: (() -> Void)? //swiftlint:disable:next large_tuple - var recordFlagEvaluationEventsReceivedArguments: (flagKey: LDFlagKey, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser)? - func recordFlagEvaluationEvents(flagKey: LDFlagKey, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser) { + var recordFlagEvaluationEventsReceivedArguments: (flagKey: LDFlagKey, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser, includeReason: Bool)? + func recordFlagEvaluationEvents(flagKey: LDFlagKey, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser, includeReason: Bool) { recordFlagEvaluationEventsCallCount += 1 - recordFlagEvaluationEventsReceivedArguments = (flagKey: flagKey, value: value, defaultValue: defaultValue, featureFlag: featureFlag, user: user) + recordFlagEvaluationEventsReceivedArguments = (flagKey: flagKey, value: value, defaultValue: defaultValue, featureFlag: featureFlag, user: user, includeReason: includeReason) recordFlagEvaluationEventsCallback?() } diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 7d19e21a..c2823b1d 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -393,9 +393,35 @@ public class LDClient { - returns: The requested feature flag value, or the fallback if the flag is missing or cannot be cast to the fallback type, or the client is not started */ + /// - Tag: variationWithFallback public func variation(forKey flagKey: LDFlagKey, fallback: T) -> T { return variation(forKey: flagKey, fallback: fallback as T?) ?? fallback //the fallback cast to 'as T?' directs the call to the Optional-returning variation method } + + /** + Returns the EvaluationDetail for the given feature flag. EvaluationDetail gives you more insight into why your variation contains the specified value. If the flag does not exist, cannot be cast to the correct return type, or the LDClient is not started, returns EvaluationDetail with the fallback value. Use this method when the fallback value is a non-Optional type. See `variationDetail` with the Optional return value when the fallback value can be nil. See [variationWithFallback](x-source-tag://variationWithFallback) + + - parameter forKey: The LDFlagKey for the requested feature flag. + - parameter fallback: The fallback value to return if the feature flag key does not exist. + + - returns: EvaluationDetail which wraps the requested feature flag value, or the fallback, which variation was served, and the evaluation reason. + */ + public func variationDetail(forKey flagKey: LDFlagKey, fallback: T) -> EvaluationDetail { + let featureFlag = user.flagStore.featureFlag(for: flagKey) + let reason = checkErrorKinds(featureFlag: featureFlag) ?? featureFlag?.reason + let (value, _) = variationAndSourceInternal(forKey: flagKey, fallback: fallback, includeReason: true) + return EvaluationDetail(value: value ?? fallback, variationIndex: featureFlag?.variation, reason: reason) + } + + private func checkErrorKinds(featureFlag: FeatureFlag?) -> Dictionary? { + if !hasStarted { + return ["kind": "ERROR", "errorKind": "CLIENT_NOT_READY"] + } else if featureFlag == nil { + return ["kind": "ERROR", "errorKind": "FLAG_NOT_FOUND"] + } else { + return nil + } + } /** Returns the variation for the given feature flag. If the flag does not exist, cannot be cast to the correct return type, or the LDClient is not started, returns the fallback value, which may be `nil`. Use this method when the fallback value is an Optional type. See `variation` with the non-Optional return value when the fallback value cannot be nil. @@ -438,10 +464,26 @@ public class LDClient { - returns: The requested feature flag value, or the fallback if the flag is missing or cannot be cast to the fallback type, or the client is not started */ + /// - Tag: variationWithoutFallback public func variation(forKey flagKey: LDFlagKey, fallback: T? = nil) -> T? { - let (value, _) = variationAndSource(forKey: flagKey, fallback: fallback) + let (value, _) = variationAndSourceInternal(forKey: flagKey, fallback: fallback, includeReason: false) return value } + + /** + Returns the EvaluationDetail for the given feature flag. EvaluationDetail gives you more insight into why your variation contains the specified value. If the flag does not exist, cannot be cast to the correct return type, or the LDClient is not started, returns EvaluationDetail with the fallback value, which may be `nil`. Use this method when the fallback value is a Optional type. See [variationWithoutFallback](x-source-tag://variationWithoutFallback) + + - parameter forKey: The LDFlagKey for the requested feature flag. + - parameter fallback: The fallback value to return if the feature flag key does not exist. If omitted, the fallback value is `nil`. (Optional) + + - returns: EvaluationDetail which wraps the requested feature flag value, or the fallback, which variation was served, and the evaluation reason. + */ + public func variationDetail(forKey flagKey: LDFlagKey, fallback: T? = nil) -> EvaluationDetail { + let featureFlag = user.flagStore.featureFlag(for: flagKey) + let reason = checkErrorKinds(featureFlag: featureFlag) ?? featureFlag?.reason + let (value, _) = variationAndSourceInternal(forKey: flagKey, fallback: fallback, includeReason: true) + return EvaluationDetail(value: value, variationIndex: featureFlag?.variation, reason: reason) + } /** Returns the variation and source for the given feature flag as a tuple. If the flag does not exist, cannot be cast to the correct return type, or the LDClient is not started, returns the fallback value and `.fallback` for the source. Use this method when the fallback value is a non-Optional type. See `variationAndSource` with the Optional return value when the fallback value can be nil. @@ -480,6 +522,7 @@ public class LDClient { - returns: A tuple containing the requested feature flag value and source, or the fallback if the flag is missing or cannot be cast to the fallback type, or the client is not started. If the fallback value is returned, the source is `.fallback` */ + @available(*, deprecated, message: "Please use the variationDetail method for additional insight into flag evaluation.") public func variationAndSource(forKey flagKey: LDFlagKey, fallback: T) -> (T, LDFlagValueSource) { let (value, source) = variationAndSource(forKey: flagKey, fallback: fallback as T?) return (value ?? fallback, source) //Because the fallback is wrapped into an Optional, the nil coalescing right side should never be called @@ -526,7 +569,17 @@ public class LDClient { - returns: A tuple containing the requested feature flag value and source, or the fallback if the flag is missing or cannot be cast to the fallback type, or the client is not started. If the fallback value is returned, the source is `.fallback` */ + @available(*, deprecated, message: "Please use the variationDetail method for additional insight into flag evaluation.") public func variationAndSource(forKey flagKey: LDFlagKey, fallback: T? = nil) -> (T?, LDFlagValueSource) { + return variationAndSourceInternal(forKey: flagKey, fallback: fallback, includeReason: false) + } + + internal func variationAndSourceInternal(forKey flagKey: LDFlagKey, fallback: T) -> (T, LDFlagValueSource) { + let (value, source) = variationAndSourceInternal(forKey: flagKey, fallback: fallback as T?, includeReason: false) + return (value ?? fallback, source) //Because the fallback is wrapped into an Optional, the nil coalescing right side should never be called + } + + internal func variationAndSourceInternal(forKey flagKey: LDFlagKey, fallback: T? = nil, includeReason: Bool? = false) -> (T?, LDFlagValueSource) { guard hasStarted else { Log.debug(typeName(and: #function) + "returning fallback: \(fallback.stringValue), source: \(LDFlagValueSource.fallback)." + " LDClient not started.") @@ -535,9 +588,9 @@ public class LDClient { let (featureFlag, flagStoreSource) = user.flagStore.featureFlagAndSource(for: flagKey) let (value, source): (T?, LDFlagValueSource) = valueAndSource(from: featureFlag, fallback: fallback, source: flagStoreSource) let failedConversionMessage = self.failedConversionMessage(featureFlag: featureFlag, source: source, fallback: fallback) - Log.debug(typeName(and: #function) + "flagKey: \(flagKey), value: \(value.stringValue), fallback: \(fallback.stringValue), featureFlag: \(featureFlag.stringValue), source: \(source)." + Log.debug(typeName(and: #function) + "flagKey: \(flagKey), value: \(value.stringValue), fallback: \(fallback.stringValue), featureFlag: \(featureFlag.stringValue), source: \(source), reason: \(featureFlag?.reason?.description ?? "No evaluation reason")." + "\(failedConversionMessage)") - eventReporter.recordFlagEvaluationEvents(flagKey: flagKey, value: value, defaultValue: fallback, featureFlag: featureFlag, user: user) + eventReporter.recordFlagEvaluationEvents(flagKey: flagKey, value: value, defaultValue: fallback, featureFlag: featureFlag, user: user, includeReason: includeReason ?? false) return (value, source) } @@ -816,17 +869,18 @@ public class LDClient { - parameter key: The key for the event. The SDK does nothing with the key, which can be any string the client app sends - parameter data: The data for the event. The SDK does nothing with the data, which can be any valid JSON item the client app sends. (Optional) + - parameter metricValue: A numeric value used by the LaunchDarkly experimentation feature in numeric custom metrics. Can be omitted if this event is used by only non-numeric metrics. This field will also be returned as part of the custom event for Data Export. (Optional) - throws: JSONSerialization.JSONError.invalidJsonObject if the data is not a valid JSON item */ - public func trackEvent(key: String, data: Any? = nil) throws { + public func trackEvent(key: String, data: Any? = nil, metricValue: Double? = nil) throws { guard hasStarted else { Log.debug(typeName(and: #function) + "aborted. LDClient not started") return } - let event = try Event.customEvent(key: key, user: user, data: data) - Log.debug(typeName(and: #function) + "event: \(event), data: \(String(describing: data))") + let event = try Event.customEvent(key: key, user: user, data: data, metricValue: metricValue) + Log.debug(typeName(and: #function) + "event: \(event), data: \(String(describing: data)), metricValue: \(String(describing: metricValue))") eventReporter.record(event) } diff --git a/LaunchDarkly/LaunchDarkly/Models/Event.swift b/LaunchDarkly/LaunchDarkly/Models/Event.swift index 69584661..c74b44bd 100644 --- a/LaunchDarkly/LaunchDarkly/Models/Event.swift +++ b/LaunchDarkly/LaunchDarkly/Models/Event.swift @@ -10,7 +10,7 @@ import Foundation struct Event { //sdk internal, not publically accessible enum CodingKeys: String, CodingKey { - case key, kind, creationDate, user, userKey, value, defaultValue = "default", variation, version, data, endDate + case key, kind, creationDate, user, userKey, value, defaultValue = "default", variation, version, data, endDate, reason, metricValue } enum Kind: String { @@ -43,6 +43,8 @@ struct Event { //sdk internal, not publically accessible let data: Any? let flagRequestTracker: FlagRequestTracker? let endDate: Date? + let includeReason: Bool + let metricValue: Double? init(kind: Kind = .custom, key: String? = nil, @@ -52,8 +54,9 @@ struct Event { //sdk internal, not publically accessible featureFlag: FeatureFlag? = nil, data: Any? = nil, flagRequestTracker: FlagRequestTracker? = nil, - endDate: Date? = nil) { - + endDate: Date? = nil, + includeReason: Bool = false, + metricValue: Double? = nil) { self.kind = kind self.key = key self.creationDate = kind == .summary ? nil : Date() @@ -64,29 +67,32 @@ struct Event { //sdk internal, not publically accessible self.data = data self.flagRequestTracker = flagRequestTracker self.endDate = endDate + self.includeReason = includeReason + self.metricValue = metricValue } - static func featureEvent(key: String, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser) -> Event { - Log.debug(typeName(and: #function) + "key: " + key + ", value: \(String(describing: value)), " + "fallback: \(String(describing: defaultValue)), " + // swiftlint:disable function_parameter_count + static func featureEvent(key: String, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser, includeReason: Bool) -> Event { + Log.debug(typeName(and: #function) + "key: " + key + ", value: \(String(describing: value)), " + "fallback: \(String(describing: defaultValue) + "reason: \(String(describing: includeReason))"), " + "featureFlag: \(String(describing: featureFlag))") - return Event(kind: .feature, key: key, user: user, value: value, defaultValue: defaultValue, featureFlag: featureFlag) + return Event(kind: .feature, key: key, user: user, value: value, defaultValue: defaultValue, featureFlag: featureFlag, includeReason: includeReason) } - static func debugEvent(key: String, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag, user: LDUser) -> Event { - Log.debug(typeName(and: #function) + "key: " + key + ", value: \(String(describing: value)), " + "fallback: \(String(describing: defaultValue)), " + static func debugEvent(key: String, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag, user: LDUser, includeReason: Bool) -> Event { + Log.debug(typeName(and: #function) + "key: " + key + ", value: \(String(describing: value)), " + "fallback: \(String(describing: defaultValue) + "reason: \(String(describing: includeReason))"), " + "featureFlag: \(String(describing: featureFlag))") - return Event(kind: .debug, key: key, user: user, value: value, defaultValue: defaultValue, featureFlag: featureFlag) + return Event(kind: .debug, key: key, user: user, value: value, defaultValue: defaultValue, featureFlag: featureFlag, includeReason: includeReason) } - static func customEvent(key: String, user: LDUser, data: Any? = nil) throws -> Event { - Log.debug(typeName(and: #function) + "key: " + key + ", data: \(String(describing: data))") + static func customEvent(key: String, user: LDUser, data: Any? = nil, metricValue: Double? = nil) throws -> Event { + Log.debug(typeName(and: #function) + "key: " + key + ", data: \(String(describing: data)), metricValue: \(String(describing: metricValue))") if let data = data { guard JSONSerialization.isValidJSONObject([CodingKeys.data.rawValue: data]) //the top level object must be either an array or an object for isValidJSONObject to work correctly else { throw JSONSerialization.JSONError.invalidJsonObject } } - return Event(kind: .custom, key: key, user: user, data: data) + return Event(kind: .custom, key: key, user: user, data: data, metricValue: metricValue) } static func identifyEvent(user: LDUser) -> Event { @@ -127,6 +133,8 @@ struct Event { //sdk internal, not publically accessible } } eventDictionary[CodingKeys.endDate.rawValue] = endDate?.millisSince1970 + eventDictionary[CodingKeys.reason.rawValue] = includeReason || featureFlag?.trackReason ?? false ? featureFlag?.reason : nil + eventDictionary[CodingKeys.metricValue.rawValue] = metricValue return eventDictionary } diff --git a/LaunchDarkly/LaunchDarkly/Models/FeatureFlag/EvaluationDetail.swift b/LaunchDarkly/LaunchDarkly/Models/FeatureFlag/EvaluationDetail.swift new file mode 100644 index 00000000..5c98624c --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/Models/FeatureFlag/EvaluationDetail.swift @@ -0,0 +1,21 @@ +// +// EvaluationDetail.swift +// LaunchDarkly_iOS +// +// Created by Joe Cieslik on 10/31/19. +// Copyright © 2019 Catamorphic Co. All rights reserved. +// + +import Foundation + +public final class EvaluationDetail { + public internal(set) var value: T + public internal(set) var variationIndex: Int? + public internal(set) var reason: Dictionary? + + internal init(value: T, variationIndex: Int?, reason: Dictionary?) { + self.value = value + self.variationIndex = variationIndex + self.reason = reason + } +} diff --git a/LaunchDarkly/LaunchDarkly/Models/FeatureFlag/FeatureFlag.swift b/LaunchDarkly/LaunchDarkly/Models/FeatureFlag/FeatureFlag.swift index d327fda5..52baf3dd 100644 --- a/LaunchDarkly/LaunchDarkly/Models/FeatureFlag/FeatureFlag.swift +++ b/LaunchDarkly/LaunchDarkly/Models/FeatureFlag/FeatureFlag.swift @@ -11,7 +11,7 @@ import Foundation struct FeatureFlag { enum CodingKeys: String, CodingKey, CaseIterable { - case flagKey = "key", value, variation, version, flagVersion + case flagKey = "key", value, variation, version, flagVersion, reason, trackReason } let flagKey: LDFlagKey @@ -22,14 +22,18 @@ struct FeatureFlag { ///The feature flag version. It changes whenever this feature flag changes. Used for event reporting only. Server json lists this as "flagVersion". Event json lists this as "version". let flagVersion: Int? let eventTrackingContext: EventTrackingContext? + let reason: Dictionary? + let trackReason: Bool? - init(flagKey: LDFlagKey, value: Any?, variation: Int?, version: Int?, flagVersion: Int?, eventTrackingContext: EventTrackingContext?) { + init(flagKey: LDFlagKey, value: Any?, variation: Int?, version: Int?, flagVersion: Int?, eventTrackingContext: EventTrackingContext?, reason: Dictionary?, trackReason: Bool?) { self.flagKey = flagKey self.value = value is NSNull ? nil : value self.variation = variation self.version = version self.flagVersion = flagVersion self.eventTrackingContext = eventTrackingContext + self.reason = reason + self.trackReason = trackReason } init?(dictionary: [String: Any]?) { @@ -43,7 +47,9 @@ struct FeatureFlag { variation: dictionary.variation, version: dictionary.version, flagVersion: dictionary.flagVersion, - eventTrackingContext: EventTrackingContext(dictionary: dictionary)) + eventTrackingContext: EventTrackingContext(dictionary: dictionary), + reason: dictionary.reason, + trackReason: dictionary.trackReason) } var dictionaryValue: [String: Any] { @@ -53,6 +59,8 @@ struct FeatureFlag { dictionaryValue[CodingKeys.variation.rawValue] = variation ?? NSNull() dictionaryValue[CodingKeys.version.rawValue] = version ?? NSNull() dictionaryValue[CodingKeys.flagVersion.rawValue] = flagVersion ?? NSNull() + dictionaryValue[CodingKeys.reason.rawValue] = reason ?? NSNull() + dictionaryValue[CodingKeys.trackReason.rawValue] = trackReason ?? NSNull() if let eventTrackingContext = eventTrackingContext { dictionaryValue.merge(eventTrackingContext.dictionaryValue) { (_, eventTrackingContextValue) in return eventTrackingContextValue //this should never happen since the feature flag dictionary does not have any keys also used by the eventTrackingContext dictionary @@ -68,23 +76,17 @@ extension FeatureFlag: Equatable { if lhs.flagKey != rhs.flagKey { return false } - if lhs.variation == nil { - if rhs.variation != nil { - return false - } - } else { - if lhs.variation != rhs.variation { - return false - } + if lhs.variation == nil && rhs.variation != nil || lhs.variation != rhs.variation { + return false } - if lhs.version == nil { - if rhs.version != nil { - return false - } - } else { - if lhs.version != rhs.version { - return false - } + if lhs.version == nil && rhs.version != nil || lhs.version != rhs.version { + return false + } + if lhs.reason == nil && rhs.reason != nil || lhs.reason != rhs.reason { + return false + } + if lhs.trackReason == nil && rhs.trackReason != nil || lhs.trackReason != rhs.trackReason { + return false } return true } @@ -128,6 +130,14 @@ extension Dictionary where Key == String, Value == Any { var flagVersion: Int? { return self[FeatureFlag.CodingKeys.flagVersion.rawValue] as? Int } + + var reason: Dictionary? { + return self[FeatureFlag.CodingKeys.reason.rawValue] as? Dictionary + } + + var trackReason: Bool? { + return self[FeatureFlag.CodingKeys.trackReason.rawValue] as? Bool + } var flagCollection: [LDFlagKey: FeatureFlag]? { guard !(self is [LDFlagKey: FeatureFlag]) diff --git a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift index 3957ea54..7a718408 100644 --- a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift +++ b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift @@ -66,6 +66,9 @@ public struct LDConfig { /// The default setting controlling information logged to the console, and modifying some setting ranges to facilitate debugging. (false) static let debugMode = false + + /// The default setting for whether we request evaluation reasons for all flags. + static let evaluationReasons = false } /// The minimum values allowed to be set into LDConfig. @@ -171,13 +174,16 @@ public struct LDConfig { */ public var inlineUserInEvents: Bool = Defaults.inlineUserInEvents - ///Enables logging for debugging. (Default: false) + /// Enables logging for debugging. (Default: false) public var isDebugMode: Bool = Defaults.debugMode + + /// Enables requesting evaluation reasons for all flags. (Default: false) + public var evaluationReasons: Bool = Defaults.evaluationReasons - ///LaunchDarkly defined minima for selected configurable items + /// LaunchDarkly defined minima for selected configurable items public let minima: Minima - ///An NSObject wrapper for the Swift LDConfig struct. Intended for use in mixed apps when Swift code needs to pass a config into an Objective-C method. + /// An NSObject wrapper for the Swift LDConfig struct. Intended for use in mixed apps when Swift code needs to pass a config into an Objective-C method. public var objcLdConfig: ObjcLDConfig { return ObjcLDConfig(self) } @@ -234,6 +240,7 @@ extension LDConfig: Equatable { && lhs.useReport == rhs.useReport && lhs.inlineUserInEvents == rhs.inlineUserInEvents && lhs.isDebugMode == rhs.isDebugMode + && lhs.evaluationReasons == rhs.evaluationReasons } } diff --git a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift index 5f93dfb7..ccd5e7ac 100644 --- a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift +++ b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift @@ -70,6 +70,10 @@ final class DarklyService: DarklyServiceProvider { static let report = "REPORT" } + struct ReasonsPath { + static let reasons = URLQueryItem(name: "withReasons", value: "true") + } + let config: LDConfig let user: LDUser let httpHeaders: HTTPHeaders @@ -135,7 +139,7 @@ final class DarklyService: DarklyServiceProvider { private func flagRequestUrl(useReport: Bool) -> URL? { if useReport { - return config.baseUrl.appendingPathComponent(FlagRequestPath.report) + return shouldGetReasons(url: config.baseUrl.appendingPathComponent(FlagRequestPath.report)) } guard let encodedUser = user .dictionaryValue(includeFlagConfig: false, includePrivateAttributes: true, config: config) @@ -143,7 +147,17 @@ final class DarklyService: DarklyServiceProvider { else { return nil } - return config.baseUrl.appendingPathComponent(FlagRequestPath.get).appendingPathComponent(encodedUser) + return shouldGetReasons(url: config.baseUrl.appendingPathComponent(FlagRequestPath.get).appendingPathComponent(encodedUser)) + } + + private func shouldGetReasons(url: URL) -> URL { + if config.evaluationReasons { + var urlComponent = URLComponents(url: url, resolvingAgainstBaseURL: false) + urlComponent?.queryItems = [ReasonsPath.reasons] + return urlComponent?.url ?? url + } else { + return url + } } private func processEtag(from serviceResponse: ServiceResponse) { @@ -181,13 +195,13 @@ final class DarklyService: DarklyServiceProvider { } private var getStreamRequestUrl: URL { - return config.streamUrl.appendingPathComponent(StreamRequestPath.meval) + return shouldGetReasons(url: config.streamUrl.appendingPathComponent(StreamRequestPath.meval) .appendingPathComponent(user .dictionaryValue(includeFlagConfig: false, includePrivateAttributes: true, config: config) - .base64UrlEncodedString ?? "") + .base64UrlEncodedString ?? "")) } private var reportStreamRequestUrl: URL { - return config.streamUrl.appendingPathComponent(StreamRequestPath.meval) + return shouldGetReasons(url: config.streamUrl.appendingPathComponent(StreamRequestPath.meval)) } // MARK: Publish Events diff --git a/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjCEvaluationDetail.swift b/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjCEvaluationDetail.swift new file mode 100644 index 00000000..58f5a956 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjCEvaluationDetail.swift @@ -0,0 +1,81 @@ +// +// ObjCEvaluationDetail.swift +// LaunchDarkly_iOS +// +// Created by Joe Cieslik on 12/1/19. +// Copyright © 2019 Catamorphic Co. All rights reserved. +// + +import Foundation + +@objc public final class ObjCBoolEvaluationDetail: NSObject { + public internal(set) var value: Bool + public internal(set) var variationIndex: Int? + public internal(set) var reason: Dictionary? + + internal init(value: Bool, variationIndex: Int?, reason: Dictionary?) { + self.value = value + self.variationIndex = variationIndex + self.reason = reason + } +} + +@objc public final class ObjCDoubleEvaluationDetail: NSObject { + public internal(set) var value: Double + public internal(set) var variationIndex: Int? + public internal(set) var reason: Dictionary? + + internal init(value: Double, variationIndex: Int?, reason: Dictionary?) { + self.value = value + self.variationIndex = variationIndex + self.reason = reason + } +} + +@objc public final class ObjCIntegerEvaluationDetail: NSObject { + public internal(set) var value: Int + public internal(set) var variationIndex: Int? + public internal(set) var reason: Dictionary? + + internal init(value: Int, variationIndex: Int?, reason: Dictionary?) { + self.value = value + self.variationIndex = variationIndex + self.reason = reason + } +} + +@objc public final class ObjCStringEvaluationDetail: NSObject { + public internal(set) var value: String? + public internal(set) var variationIndex: Int? + public internal(set) var reason: Dictionary? + + internal init(value: String?, variationIndex: Int?, reason: Dictionary?) { + self.value = value + self.variationIndex = variationIndex + self.reason = reason + } +} + +@objc public final class ObjCArrayEvaluationDetail: NSObject { + public internal(set) var value: [Any]? + public internal(set) var variationIndex: Int? + public internal(set) var reason: Dictionary? + + internal init(value: [Any]?, variationIndex: Int?, reason: Dictionary?) { + self.value = value + self.variationIndex = variationIndex + self.reason = reason + } +} + +@objc public final class ObjCDictionaryEvaluationDetail: NSObject { + public internal(set) var value: Dictionary? + public internal(set) var variationIndex: Int? + public internal(set) var reason: Dictionary? + + internal init(value: Dictionary?, variationIndex: Int?, reason: Dictionary?) { + self.value = value + self.variationIndex = variationIndex + self.reason = reason + } +} diff --git a/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift b/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift index 0114e7be..538cba13 100644 --- a/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift +++ b/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift @@ -47,6 +47,7 @@ import Foundation ```` The `changedFlag` passed in to the block contains the old and new value, and the old and new valueSource. See the typed `LDChangedFlag` classes in the **Obj-C Changed Flags**. */ +// swiftlint:disable file_length @objc(LDClient) public final class ObjcLDClient: NSObject { @@ -210,10 +211,24 @@ public final class ObjcLDClient: NSObject { - returns: The requested BOOL feature flag value, or the fallback if the flag is missing or cannot be cast to a BOOL, or the client is not started */ + /// - Tag: boolVariation @objc public func boolVariation(forKey key: LDFlagKey, fallback: Bool) -> Bool { return LDClient.shared.variation(forKey: key, fallback: fallback) } + /** + See [boolVariation](x-source-tag://boolVariation) for more information on variation methods. + + - parameter key: The LDFlagKey for the requested feature flag. + - parameter fallback: The fallback value to return if the feature flag key does not exist. + + - returns: ObjCBoolEvaluationDetail: This class contains your value as well as useful information on why that value was returned. + */ + @objc public func boolVariationDetail(forKey key: LDFlagKey, fallback: Bool) -> ObjCBoolEvaluationDetail { + let evaluationDetail = LDClient.shared.variationDetail(forKey: key, fallback: fallback) + return ObjCBoolEvaluationDetail(value: evaluationDetail.value, variationIndex: evaluationDetail.variationIndex, reason: evaluationDetail.reason) + } + /** Returns the `LDBoolVariationValue` (`ObjcLDBoolVariationValue`) containing the value and source for the given feature flag. If the flag does not exist, cannot be cast to a BOOL, or the LDClient is not started, returns the fallback value and `LDFlagValueSourceFallback` for the source. @@ -239,8 +254,9 @@ public final class ObjcLDClient: NSObject { - returns: A `LDBoolVariationValue` (`ObjcLDBoolVariationValue`) containing the requested feature flag value and source, or the fallback if the flag is missing or cannot be cast to a BOOL, or the client is not started. If the fallback value is returned, the source is `LDFlagValueSourceFallback` */ + @available(*, deprecated, message: "Please use the boolVariationDetail method for additional insight into flag evaluation.") @objc public func boolVariationAndSource(forKey key: LDFlagKey, fallback: Bool) -> ObjcLDBoolVariationValue { - return ObjcLDBoolVariationValue(LDClient.shared.variationAndSource(forKey: key, fallback: fallback)) + return ObjcLDBoolVariationValue(LDClient.shared.variationAndSourceInternal(forKey: key, fallback: fallback)) } /** @@ -266,9 +282,23 @@ public final class ObjcLDClient: NSObject { - returns: The requested NSInteger feature flag value, or the fallback if the flag is missing or cannot be cast to a NSInteger, or the client is not started */ + /// - Tag: integerVariation @objc public func integerVariation(forKey key: LDFlagKey, fallback: Int) -> Int { return LDClient.shared.variation(forKey: key, fallback: fallback) } + + /** + See [integerVariation](x-source-tag://integerVariation) for more information on variation methods. + + - parameter key: The LDFlagKey for the requested feature flag. + - parameter fallback: The fallback value to return if the feature flag key does not exist. + + - returns: ObjCIntegerEvaluationDetail: This class contains your value as well as useful information on why that value was returned. + */ + @objc public func integerVariationDetail(forKey key: LDFlagKey, fallback: Int) -> ObjCIntegerEvaluationDetail { + let evaluationDetail = LDClient.shared.variationDetail(forKey: key, fallback: fallback) + return ObjCIntegerEvaluationDetail(value: evaluationDetail.value, variationIndex: evaluationDetail.variationIndex, reason: evaluationDetail.reason) + } /** Returns the `LDIntegerVariationValue` (`ObjcLDIntegerVariationValue`) containing the value and source for the given feature flag. If the flag does not exist, cannot be cast to a NSInteger, or the LDClient is not started, returns the fallback value and `LDFlagValueSourceFallback` for the source. @@ -295,8 +325,9 @@ public final class ObjcLDClient: NSObject { - returns: A `LDIntegerVariationValue` (`ObjcLDIntegerVariationValue`) containing the requested feature flag value and source, or the fallback if the flag is missing or cannot be cast to a NSInteger, or the client is not started. If the fallback value is returned, the source is `LDFlagValueSourceFallback` */ + @available(*, deprecated, message: "Please use the integerVariationDetail method for additional insight into flag evaluation.") @objc public func integerVariationAndSource(forKey key: LDFlagKey, fallback: Int) -> ObjcLDIntegerVariationValue { - return ObjcLDIntegerVariationValue(LDClient.shared.variationAndSource(forKey: key, fallback: fallback)) + return ObjcLDIntegerVariationValue(LDClient.shared.variationAndSourceInternal(forKey: key, fallback: fallback)) } /** @@ -322,9 +353,23 @@ public final class ObjcLDClient: NSObject { - returns: The requested double feature flag value, or the fallback if the flag is missing or cannot be cast to a double, or the client is not started */ + /// - Tag: doubleVariation @objc public func doubleVariation(forKey key: LDFlagKey, fallback: Double) -> Double { return LDClient.shared.variation(forKey: key, fallback: fallback) } + + /** + See [doubleVariation](x-source-tag://doubleVariation) for more information on variation methods. + + - parameter key: The LDFlagKey for the requested feature flag. + - parameter fallback: The fallback value to return if the feature flag key does not exist. + + - returns: ObjCDoubleEvaluationDetail: This class contains your value as well as useful information on why that value was returned. + */ + @objc public func doubleVariationDetail(forKey key: LDFlagKey, fallback: Double) -> ObjCDoubleEvaluationDetail { + let evaluationDetail = LDClient.shared.variationDetail(forKey: key, fallback: fallback) + return ObjCDoubleEvaluationDetail(value: evaluationDetail.value, variationIndex: evaluationDetail.variationIndex, reason: evaluationDetail.reason) + } /** Returns the `LDDoubleVariationValue` (`ObjcLDDoubleVariationValue`) containing the value and source for the given feature flag. If the flag does not exist, cannot be cast to a double, or the LDClient is not started, returns the fallback value and `LDFlagValueSourceFallback` for the source. @@ -351,8 +396,9 @@ public final class ObjcLDClient: NSObject { - returns: A `LDDoubleVariationValue` (`ObjcLDDoubleVariationValue`) containing the requested feature flag value and source, or the fallback if the flag is missing or cannot be cast to a double, or the client is not started. If the fallback value is returned, the source is `LDFlagValueSourceFallback` */ + @available(*, deprecated, message: "Please use the doubleVariationDetail method for additional insight into flag evaluation.") @objc public func doubleVariationAndSource(forKey key: LDFlagKey, fallback: Double) -> ObjcLDDoubleVariationValue { - return ObjcLDDoubleVariationValue(LDClient.shared.variationAndSource(forKey: key, fallback: fallback)) + return ObjcLDDoubleVariationValue(LDClient.shared.variationAndSourceInternal(forKey: key, fallback: fallback)) } /** @@ -378,9 +424,23 @@ public final class ObjcLDClient: NSObject { - returns: The requested NSString feature flag value, or the fallback value (which may be nil) if the flag is missing or cannot be cast to a NSString, or the client is not started. */ + /// - Tag: stringVariation @objc public func stringVariation(forKey key: LDFlagKey, fallback: String?) -> String? { return LDClient.shared.variation(forKey: key, fallback: fallback) } + + /** + See [stringVariation](x-source-tag://stringVariation) for more information on variation methods. + + - parameter key: The LDFlagKey for the requested feature flag. + - parameter fallback: The fallback value to return if the feature flag key does not exist. The fallback value may be nil. + + - returns: ObjCStringEvaluationDetail: This class contains your value as well as useful information on why that value was returned. + */ + @objc public func stringVariationDetail(forKey key: LDFlagKey, fallback: String?) -> ObjCStringEvaluationDetail { + let evaluationDetail = LDClient.shared.variationDetail(forKey: key, fallback: fallback) + return ObjCStringEvaluationDetail(value: evaluationDetail.value, variationIndex: evaluationDetail.variationIndex, reason: evaluationDetail.reason) + } /** Returns the `LDStringVariationValue` (`ObjcLDStringVariationValue`) containing the value and source for the given feature flag. If the flag does not exist, cannot be cast to a NSString, or the LDClient is not started, returns the fallback value (which may be nil) and `LDFlagValueSourceFallback` for the source. @@ -407,8 +467,9 @@ public final class ObjcLDClient: NSObject { - returns: A `LDStringVariationValue` (`ObjcLDStringVariationValue`) containing the requested feature flag value and source, or the fallback value (which may be nil) if the flag is missing or cannot be cast to a NSString, or the client is not started. If the fallback value is returned, the source is `LDFlagValueSourceFallback` */ + @available(*, deprecated, message: "Please use the stringVariationDetail method for additional insight into flag evaluation.") @objc public func stringVariationAndSource(forKey key: LDFlagKey, fallback: String?) -> ObjcLDStringVariationValue { - return ObjcLDStringVariationValue(LDClient.shared.variationAndSource(forKey: key, fallback: fallback)) + return ObjcLDStringVariationValue(LDClient.shared.variationAndSourceInternal(forKey: key, fallback: fallback)) } /** @@ -434,10 +495,24 @@ public final class ObjcLDClient: NSObject { - returns: The requested NSArray feature flag value, or the fallback value (which may be nil) if the flag is missing or cannot be cast to a NSArray, or the client is not started */ + /// - Tag: arrayVariation @objc public func arrayVariation(forKey key: LDFlagKey, fallback: [Any]?) -> [Any]? { return LDClient.shared.variation(forKey: key, fallback: fallback) } + /** + See [arrayVariation](x-source-tag://arrayVariation) for more information on variation methods. + + - parameter key: The LDFlagKey for the requested feature flag. + - parameter fallback: The fallback value to return if the feature flag key does not exist. The fallback value may be nil. + + - returns: ObjCArrayEvaluationDetail: This class contains your value as well as useful information on why that value was returned. + */ + @objc public func arrayVariationDetail(forKey key: LDFlagKey, fallback: [Any]?) -> ObjCArrayEvaluationDetail { + let evaluationDetail = LDClient.shared.variationDetail(forKey: key, fallback: fallback) + return ObjCArrayEvaluationDetail(value: evaluationDetail.value, variationIndex: evaluationDetail.variationIndex, reason: evaluationDetail.reason) + } + /** Returns the `LDArrayVariationValue` (`ObjcLDArrayVariationValue`) containing the value and source for the given feature flag. If the flag does not exist, cannot be cast to a NSArray, or the LDClient is not started, returns the fallback value (which may be nil) and `LDFlagValueSourceFallback` for the source. @@ -463,8 +538,9 @@ public final class ObjcLDClient: NSObject { - returns: A `LDArrayVariationValue` (`ObjcLDArrayVariationValue`) containing the requested feature flag value and source, or the fallback value (which may be nil) if the flag is missing or cannot be cast to a NSArray, or the client is not started. If the fallback value is returned, the source is `LDFlagValueSourceFallback` */ + @available(*, deprecated, message: "Please use the arrayVariationDetail method for additional insight into flag evaluation.") @objc public func arrayVariationAndSource(forKey key: LDFlagKey, fallback: [Any]?) -> ObjcLDArrayVariationValue { - return ObjcLDArrayVariationValue(LDClient.shared.variationAndSource(forKey: key, fallback: fallback)) + return ObjcLDArrayVariationValue(LDClient.shared.variationAndSourceInternal(forKey: key, fallback: fallback)) } /** @@ -490,10 +566,24 @@ public final class ObjcLDClient: NSObject { - returns: The requested NSDictionary feature flag value, or the fallback value (which may be nil) if the flag is missing or cannot be cast to a NSDictionary, or the client is not started */ + /// - Tag: dictionaryVariation @objc public func dictionaryVariation(forKey key: LDFlagKey, fallback: [String: Any]?) -> [String: Any]? { return LDClient.shared.variation(forKey: key, fallback: fallback) } + /** + See [dictionaryVariation](x-source-tag://dictionaryVariation) for more information on variation methods. + + - parameter key: The LDFlagKey for the requested feature flag. + - parameter fallback: The fallback value to return if the feature flag key does not exist. The fallback value may be nil. + + - returns: ObjCDictionaryEvaluationDetail: This class contains your value as well as useful information on why that value was returned. + */ + @objc public func dictionaryVariationDetail(forKey key: LDFlagKey, fallback: [String: Any]?) -> ObjCDictionaryEvaluationDetail { + let evaluationDetail = LDClient.shared.variationDetail(forKey: key, fallback: fallback) + return ObjCDictionaryEvaluationDetail(value: evaluationDetail.value, variationIndex: evaluationDetail.variationIndex, reason: evaluationDetail.reason) + } + /** Returns the `LDDictionaryVariationValue` (`ObjcLDDictionaryVariationValue`) containing the value and source for the given feature flag. If the flag does not exist, cannot be cast to a NSDictionary, or the LDClient is not started, returns the fallback value (which may be nil) and `LDFlagValueSourceFallback` for the source. @@ -519,8 +609,9 @@ public final class ObjcLDClient: NSObject { - returns: A `LDDictionaryVariationValue` (`ObjcLDDictionaryVariationValue`) containing the requested feature flag value and source, or the fallback value (which may be nil) if the flag is missing or cannot be cast to a NSDictionary, or the client is not started. If the fallback value is returned, the source is `LDFlagValueSourceFallback` */ + @available(*, deprecated, message: "Please use the dictionaryVariationDetail method for additional insight into flag evaluation.") @objc public func dictionaryVariationAndSource(forKey key: LDFlagKey, fallback: [String: Any]?) -> ObjcLDDictionaryVariationValue { - return ObjcLDDictionaryVariationValue(LDClient.shared.variationAndSource(forKey: key, fallback: fallback)) + return ObjcLDDictionaryVariationValue(LDClient.shared.variationAndSourceInternal(forKey: key, fallback: fallback)) } /** @@ -896,8 +987,21 @@ public final class ObjcLDClient: NSObject { - parameter data: The data for the event. The SDK does nothing with the data, which can be any valid JSON item the client app sends. (Optional) - parameter error: NSError object to hold the invalidJsonObject error if the data is not a valid JSON item. (Optional) */ + /// - Tag: trackEvent @objc public func trackEvent(key: String, data: Any? = nil) throws { - try LDClient.shared.trackEvent(key: key, data: data) + try LDClient.shared.trackEvent(key: key, data: data, metricValue: nil) + } + + /** + See (trackEvent)[x-source-tag://trackEvent] for full documentation. + + - parameter key: The key for the event. The SDK does nothing with the key, which can be any string the client app sends + - parameter data: The data for the event. The SDK does nothing with the data, which can be any valid JSON item the client app sends. (Optional) + - parameter metricValue: A numeric value used by the LaunchDarkly experimentation feature in numeric custom metrics. Can be omitted if this event is used by only non-numeric metrics. This field will also be returned as part of the custom event for Data Export. + - parameter error: NSError object to hold the invalidJsonObject error if the data is not a valid JSON item. (Optional) + */ + @objc public func trackEvent(key: String, data: Any? = nil, metricValue: Double) throws { + try LDClient.shared.trackEvent(key: key, data: data, metricValue: metricValue) } /** diff --git a/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCache.swift b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCache.swift index 256364bf..e38061e4 100644 --- a/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCache.swift +++ b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCache.swift @@ -10,7 +10,7 @@ import Foundation //sourcery: autoMockable protocol DeprecatedCache { - //sourcery: defaultMockValue = .version5 + //sourcery: defaultMockValue = .version6 var model: DeprecatedCacheModel { get } //sourcery: defaultMockValue = CacheConverter.CacheKeys.cachedDataKeyStub var cachedDataKey: String { get } @@ -45,7 +45,7 @@ extension DeprecatedCache { } enum DeprecatedCacheModel: String, CaseIterable { - case version5, version4, version3, version2 //version1 is not supported + case version6, version5, version4, version3, version2 //version1 is not supported } // updatedAt in cached data was used as the LDUser.lastUpdated, which is deprecated in the Swift SDK diff --git a/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV2.swift b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV2.swift index d57d43da..964febf3 100644 --- a/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV2.swift +++ b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV2.swift @@ -47,7 +47,7 @@ final class DeprecatedCacheModelV2: DeprecatedCache { return (nil, nil) } let featureFlags = Dictionary(uniqueKeysWithValues: featureFlagDictionaries.compactMap { (flagKey, value) in - return (flagKey, FeatureFlag(flagKey: flagKey, value: value, variation: nil, version: nil, flagVersion: nil, eventTrackingContext: nil)) + return (flagKey, FeatureFlag(flagKey: flagKey, value: value, variation: nil, version: nil, flagVersion: nil, eventTrackingContext: nil, reason: nil, trackReason: nil)) }) return (featureFlags, cachedUserDictionary.lastUpdated) } diff --git a/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV3.swift b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV3.swift index 030d417c..229d21ef 100644 --- a/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV3.swift +++ b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV3.swift @@ -57,7 +57,9 @@ final class DeprecatedCacheModelV3: DeprecatedCache { variation: nil, version: flagValueDictionary.version, flagVersion: nil, - eventTrackingContext: nil)) + eventTrackingContext: nil, + reason: nil, + trackReason: nil)) }) return (featureFlags, cachedUserDictionary.lastUpdated) } diff --git a/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV4.swift b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV4.swift index 3bed7c43..67cea594 100644 --- a/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV4.swift +++ b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV4.swift @@ -61,7 +61,9 @@ final class DeprecatedCacheModelV4: DeprecatedCache { variation: flagValueDictionary.variation, version: flagValueDictionary.version, flagVersion: flagValueDictionary.flagVersion, - eventTrackingContext: EventTrackingContext(dictionary: flagValueDictionary))) + eventTrackingContext: EventTrackingContext(dictionary: flagValueDictionary), + reason: nil, + trackReason: nil)) }) return (featureFlags, cachedUserDictionary.lastUpdated) } diff --git a/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV5.swift b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV5.swift index a7f9b07f..5116e9ce 100644 --- a/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV5.swift +++ b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV5.swift @@ -8,7 +8,7 @@ import Foundation -//Cache model in use from 2.14.0 up to 4.0.0 +//Cache model in use from 2.14.0 up to 4.2.0 /* [: [ “userKey”: , //LDUserEnvironment dictionary @@ -74,7 +74,9 @@ final class DeprecatedCacheModelV5: DeprecatedCache { variation: featureFlagDictionary.variation, version: featureFlagDictionary.version, flagVersion: featureFlagDictionary.flagVersion, - eventTrackingContext: EventTrackingContext(dictionary: featureFlagDictionary))) + eventTrackingContext: EventTrackingContext(dictionary: featureFlagDictionary), + reason: nil, + trackReason: nil)) }) return (featureFlags, cachedUserDictionary.lastUpdated) } @@ -88,29 +90,3 @@ final class DeprecatedCacheModelV5: DeprecatedCache { } } } - -extension Dictionary where Key == String, Value == Any { - var environments: [MobileKey: [String: Any]]? { - return self[DeprecatedCacheModelV5.CacheKeys.environments] as? [MobileKey: [String: Any]] - } -} - -extension Dictionary where Key == MobileKey, Value == [String: Any] { - var lastUpdatedDates: [Date]? { - return compactMap { (_, userDictionary) in - return userDictionary.lastUpdated - } - } -} - -extension Array where Element == Date { - var youngest: Date? { - return sorted.last - } - - var sorted: [Date] { - return self.sorted { (date1, date2) -> Bool in - date1.isEarlierThan(date2) - } - } -} diff --git a/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV6.swift b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV6.swift new file mode 100644 index 00000000..dc8249d2 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/Service Objects/Cache/DeprecatedCacheModelV6.swift @@ -0,0 +1,120 @@ +// +// DeprecatedCacheModelV6.swift +// LaunchDarkly_iOS +// +// Created by Joe Cieslik on 11/4/19. +// Copyright © 2019 Catamorphic Co. All rights reserved. +// + +import Foundation + +//Cache model in use from 4.3.0 +/* +[: [ + “userKey”: , //LDUserEnvironment dictionary + “environments”: [ + : [ + “key: , //LDUserModel dictionary + “ip”: , + “country”: , + “email”: , + “name”: , + “firstName”: , + “lastName”: , + “avatar”: , + “custom”: [ + “device”: , + “os”: , + ...], + “anonymous”: , + “updatedAt: , + ”config”: [ + : [ //LDFlagConfigModel dictionary + “version”: , //LDFlagConfigValue dictionary + “flagVersion”: , + “variation”: , + “value”: , + “trackEvents”: , //LDEventTrackingContext + “debugEventsUntilDate”: , + "reason": , + "trackReason": + ] + ], + “privateAttrs”: + ] + ] + ] +] +*/ +final class DeprecatedCacheModelV6: DeprecatedCache { + + struct CacheKeys { + static let userEnvironments = "com.launchdarkly.dataManager.userEnvironments" + static let environments = "environments" + } + + let model = DeprecatedCacheModel.version6 + let keyedValueCache: KeyedValueCaching + let cachedDataKey = CacheKeys.userEnvironments + + init(keyedValueCache: KeyedValueCaching) { + self.keyedValueCache = keyedValueCache + } + + func retrieveFlags(for userKey: UserKey, and mobileKey: MobileKey) -> (featureFlags: [LDFlagKey: FeatureFlag]?, lastUpdated: Date?) { + guard let cachedUserEnvironmentsCollection = keyedValueCache.dictionary(forKey: cachedDataKey), !cachedUserEnvironmentsCollection.isEmpty, + let cachedUserEnvironments = cachedUserEnvironmentsCollection[userKey] as? [String: Any], !cachedUserEnvironments.isEmpty, + let cachedEnvironments = cachedUserEnvironments[CacheKeys.environments] as? [MobileKey: [String: Any]], !cachedEnvironments.isEmpty, + let cachedUserDictionary = cachedEnvironments[mobileKey], !cachedUserDictionary.isEmpty, + let featureFlagDictionaries = cachedUserDictionary[LDUser.CodingKeys.config.rawValue] as? [LDFlagKey: [String: Any]] + else { + return (nil, nil) + } + let featureFlags = Dictionary(uniqueKeysWithValues: featureFlagDictionaries.compactMap { (flagKey, featureFlagDictionary) in + return (flagKey, FeatureFlag(flagKey: flagKey, + value: featureFlagDictionary.value, + variation: featureFlagDictionary.variation, + version: featureFlagDictionary.version, + flagVersion: featureFlagDictionary.flagVersion, + eventTrackingContext: EventTrackingContext(dictionary: featureFlagDictionary), + reason: featureFlagDictionary.reason, + trackReason: featureFlagDictionary.trackReason)) + }) + return (featureFlags, cachedUserDictionary.lastUpdated) + } + + func userKeys(from cachedUserData: [UserKey: [String: Any]], olderThan expirationDate: Date) -> [UserKey] { + return cachedUserData.filter { (_, userEnvironmentsDictionary) in + let lastUpdated = userEnvironmentsDictionary.environments?.lastUpdatedDates?.youngest ?? Date.distantFuture + return lastUpdated.isExpired(expirationDate: expirationDate) + }.map { (userKey, _) in + return userKey + } + } +} + +extension Dictionary where Key == String, Value == Any { + var environments: [MobileKey: [String: Any]]? { + return self[DeprecatedCacheModelV6.CacheKeys.environments] as? [MobileKey: [String: Any]] + } +} + +extension Dictionary where Key == MobileKey, Value == [String: Any] { + var lastUpdatedDates: [Date]? { + return compactMap { (_, userDictionary) in + return userDictionary.lastUpdated + } + } +} + +extension Array where Element == Date { + var youngest: Date? { + return sorted.last + } + + var sorted: [Date] { + return self.sorted { (date1, date2) -> Bool in + date1.isEarlierThan(date2) + } + } +} diff --git a/LaunchDarkly/LaunchDarkly/Service Objects/ClientServiceFactory.swift b/LaunchDarkly/LaunchDarkly/Service Objects/ClientServiceFactory.swift index 5a45168a..ca2f8582 100644 --- a/LaunchDarkly/LaunchDarkly/Service Objects/ClientServiceFactory.swift +++ b/LaunchDarkly/LaunchDarkly/Service Objects/ClientServiceFactory.swift @@ -51,6 +51,7 @@ final class ClientServiceFactory: ClientServiceCreating { case .version3: return DeprecatedCacheModelV3(keyedValueCache: makeKeyedValueCache()) case .version4: return DeprecatedCacheModelV4(keyedValueCache: makeKeyedValueCache()) case .version5: return DeprecatedCacheModelV5(keyedValueCache: makeKeyedValueCache()) + case .version6: return DeprecatedCacheModelV6(keyedValueCache: makeKeyedValueCache()) } } diff --git a/LaunchDarkly/LaunchDarkly/Service Objects/EventReporter.swift b/LaunchDarkly/LaunchDarkly/Service Objects/EventReporter.swift index ed64fd98..fce64519 100644 --- a/LaunchDarkly/LaunchDarkly/Service Objects/EventReporter.swift +++ b/LaunchDarkly/LaunchDarkly/Service Objects/EventReporter.swift @@ -14,7 +14,7 @@ enum EventSyncResult { } typealias EventSyncCompleteClosure = ((EventSyncResult) -> Void) - +//swiftlint:disable function_parameter_count //sourcery: autoMockable protocol EventReporting { //sourcery: defaultMockValue = LDConfig.stub @@ -28,7 +28,7 @@ protocol EventReporting { func record(_ event: Event, completion: CompletionClosure?) //sourcery: noMock func record(_ event: Event) - func recordFlagEvaluationEvents(flagKey: LDFlagKey, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser) + func recordFlagEvaluationEvents(flagKey: LDFlagKey, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser, includeReason: Bool) func recordSummaryEvent() func resetFlagRequestTracker() @@ -96,12 +96,11 @@ class EventReporter: EventReporting { } } - func recordFlagEvaluationEvents(flagKey: LDFlagKey, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser) { - recordFlagEvaluationEvents(flagKey: flagKey, value: value, defaultValue: defaultValue, featureFlag: featureFlag, user: user, completion: nil) + func recordFlagEvaluationEvents(flagKey: LDFlagKey, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser, includeReason: Bool) { + recordFlagEvaluationEvents(flagKey: flagKey, value: value, defaultValue: defaultValue, featureFlag: featureFlag, user: user, includeReason: includeReason, completion: nil) } - func recordFlagEvaluationEvents(flagKey: LDFlagKey, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser, completion: CompletionClosure? = nil) { - + func recordFlagEvaluationEvents(flagKey: LDFlagKey, value: Any?, defaultValue: Any?, featureFlag: FeatureFlag?, user: LDUser, includeReason: Bool, completion: CompletionClosure? = nil) { let recordingFeatureEvent = featureFlag?.eventTrackingContext?.trackEvents == true let recordingDebugEvent = featureFlag?.eventTrackingContext?.shouldCreateDebugEvents(lastEventReportResponseTime: lastEventResponseDate) ?? false let dispatchGroup = DispatchGroup() @@ -115,7 +114,7 @@ class EventReporter: EventReporting { } if recordingFeatureEvent { - let featureEvent = Event.featureEvent(key: flagKey, value: value, defaultValue: defaultValue, featureFlag: featureFlag, user: user) + let featureEvent = Event.featureEvent(key: flagKey, value: value, defaultValue: defaultValue, featureFlag: featureFlag, user: user, includeReason: includeReason) dispatchGroup.enter() record(featureEvent) { dispatchGroup.leave() @@ -123,7 +122,7 @@ class EventReporter: EventReporting { } if recordingDebugEvent, let featureFlag = featureFlag { - let debugEvent = Event.debugEvent(key: flagKey, value: value, defaultValue: defaultValue, featureFlag: featureFlag, user: user) + let debugEvent = Event.debugEvent(key: flagKey, value: value, defaultValue: defaultValue, featureFlag: featureFlag, user: user, includeReason: includeReason) dispatchGroup.enter() record(debugEvent) { dispatchGroup.leave() diff --git a/LaunchDarkly/LaunchDarkly/Service Objects/FlagStore.swift b/LaunchDarkly/LaunchDarkly/Service Objects/FlagStore.swift index b8517ce2..155ee236 100644 --- a/LaunchDarkly/LaunchDarkly/Service Objects/FlagStore.swift +++ b/LaunchDarkly/LaunchDarkly/Service Objects/FlagStore.swift @@ -72,8 +72,9 @@ final class FlagStore: FlagMaintaining { "key": , "value": , "variation": , - "version": - "flagVersion": + "version": , + "flagVersion": , + "reason": } */ func updateStore(updateDictionary: [String: Any], source: LDFlagValueSource, completion: CompletionClosure?) { diff --git a/LaunchDarkly/LaunchDarklyTests/Extensions/AnyComparerSpec.swift b/LaunchDarkly/LaunchDarklyTests/Extensions/AnyComparerSpec.swift index cc5ef269..6e7cc214 100644 --- a/LaunchDarkly/LaunchDarklyTests/Extensions/AnyComparerSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Extensions/AnyComparerSpec.swift @@ -126,7 +126,7 @@ final class AnyComparerSpec: QuickSpec { } it("returns true") { featureFlags.forEach { (flagKey, featureFlag) in - otherFlag = FeatureFlag(flagKey: flagKey, value: featureFlag.value, variation: nil, version: nil, flagVersion: nil, eventTrackingContext: nil) + otherFlag = FeatureFlag(flagKey: flagKey, value: featureFlag.value, variation: nil, version: nil, flagVersion: nil, eventTrackingContext: nil, reason: nil, trackReason: false) expect(AnyComparer.isEqual(featureFlag, to: otherFlag)).to(beTrue()) } diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift index f11d3eed..e80e1e3c 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift @@ -214,6 +214,7 @@ final class LDClientSpec: QuickSpec { reportEventsSpec() allFlagValuesSpec() connectionInformationSpec() + variationDetailSpec() } private func startSpec() { @@ -3068,6 +3069,37 @@ final class LDClientSpec: QuickSpec { } } } + + private func variationDetailSpec() { + var testContext: TestContext! + + describe("variationDetail") { + context("when client was started and flag key doesn't exist") { + beforeEach { + testContext = TestContext(startOnline: true, runMode: .foreground) + testContext.config.streamingMode = .streaming + testContext.subject.start(config: testContext.config) + } + it("returns FLAG_NOT_FOUND") { + let detail = testContext.subject.variationDetail(forKey: BadFlagKeys.bool, fallback: DefaultFlagValues.bool).reason + if let errorKind = detail?["errorKind"] as? String { + expect(errorKind) == "FLAG_NOT_FOUND" + } + } + } + context("when client was not started") { + beforeEach { + testContext = TestContext() + } + it("returns CLIENT_NOT_READY") { + let detail = testContext.subject.variationDetail(forKey: BadFlagKeys.bool, fallback: DefaultFlagValues.bool).reason + if let errorKind = detail?["errorKind"] as? String { + expect(errorKind) == "CLIENT_NOT_READY" + } + } + } + } + } } extension FeatureFlagCachingMock { diff --git a/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift b/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift index 9509a2b2..5135b55c 100644 --- a/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift +++ b/LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift @@ -105,6 +105,8 @@ final class DarklyServiceMock: DarklyServiceProvider { static let variation = 2 static let version = 4 static let flagVersion = 3 + static let reason = Optional(["kind": "OFF"]) + static func stubFeatureFlags(includeNullValue: Bool = true, includeVariations: Bool = true, includeVersions: Bool = true, @@ -170,6 +172,9 @@ final class DarklyServiceMock: DarklyServiceProvider { } return useAlternateFlagVersion ? flagVersion + 1 : flagVersion } + private static func reason(includeEvaluationReason: Bool) -> Dictionary? { + return includeEvaluationReason ? reason : nil + } static func stubFeatureFlag(for flagKey: LDFlagKey, includeVariation: Bool = true, @@ -178,13 +183,17 @@ final class DarklyServiceMock: DarklyServiceProvider { useAlternateValue: Bool = false, useAlternateVersion: Bool = false, useAlternateFlagVersion: Bool = false, - eventTrackingContext: EventTrackingContext? = EventTrackingContext.stub()) -> FeatureFlag { + eventTrackingContext: EventTrackingContext? = EventTrackingContext.stub(), + includeEvaluationReason: Bool = false, + includeTrackReason: Bool = false) -> FeatureFlag { return FeatureFlag(flagKey: flagKey, value: value(for: flagKey, useAlternateValue: useAlternateValue), variation: variation(for: flagKey, includeVariation: includeVariation, useAlternateValue: useAlternateValue), version: version(for: flagKey, includeVersion: includeVersion, useAlternateVersion: useAlternateValue || useAlternateVersion), flagVersion: flagVersion(for: flagKey, includeFlagVersion: includeFlagVersion, useAlternateFlagVersion: useAlternateValue || useAlternateFlagVersion), - eventTrackingContext: eventTrackingContext) + eventTrackingContext: eventTrackingContext, + reason: reason(includeEvaluationReason: includeEvaluationReason), + trackReason: includeTrackReason) } } diff --git a/LaunchDarkly/LaunchDarklyTests/Mocks/LDUserStub.swift b/LaunchDarkly/LaunchDarklyTests/Mocks/LDUserStub.swift index e3cef4a6..842350d4 100644 --- a/LaunchDarkly/LaunchDarklyTests/Mocks/LDUserStub.swift +++ b/LaunchDarkly/LaunchDarklyTests/Mocks/LDUserStub.swift @@ -67,7 +67,9 @@ extension LDUser { variation: DarklyServiceMock.Constants.variation, version: includeVersions ? DarklyServiceMock.Constants.version : nil, flagVersion: DarklyServiceMock.Constants.flagVersion, - eventTrackingContext: EventTrackingContext.stub()) + eventTrackingContext: EventTrackingContext.stub(), + reason: DarklyServiceMock.Constants.reason, + trackReason: false) return flags } diff --git a/LaunchDarkly/LaunchDarklyTests/Models/Cache/CacheableUserEnvironmentFlagsSpec.swift b/LaunchDarkly/LaunchDarklyTests/Models/Cache/CacheableUserEnvironmentFlagsSpec.swift index fcded5ad..23a1aea5 100644 --- a/LaunchDarkly/LaunchDarklyTests/Models/Cache/CacheableUserEnvironmentFlagsSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Models/Cache/CacheableUserEnvironmentFlagsSpec.swift @@ -431,13 +431,17 @@ extension FeatureFlag { variation: DarklyServiceMock.Constants.variation, version: DarklyServiceMock.Constants.version, flagVersion: DarklyServiceMock.Constants.flagVersion, - eventTrackingContext: EventTrackingContext.stub()) + eventTrackingContext: EventTrackingContext.stub(), + reason: DarklyServiceMock.Constants.reason, + trackReason: false) flagCollection[StubConstants.mobileKey] = FeatureFlag(flagKey: StubConstants.mobileKey, value: mobileKey, variation: DarklyServiceMock.Constants.variation, version: DarklyServiceMock.Constants.version, flagVersion: DarklyServiceMock.Constants.flagVersion, - eventTrackingContext: EventTrackingContext.stub()) + eventTrackingContext: EventTrackingContext.stub(), + reason: DarklyServiceMock.Constants.reason, + trackReason: false) return flagCollection } } diff --git a/LaunchDarkly/LaunchDarklyTests/Models/EventSpec.swift b/LaunchDarkly/LaunchDarklyTests/Models/EventSpec.swift index 4675f21e..43a0e901 100644 --- a/LaunchDarkly/LaunchDarklyTests/Models/EventSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Models/EventSpec.swift @@ -119,7 +119,7 @@ final class EventSpec: QuickSpec { } describe("featureEvent") { beforeEach { - event = Event.featureEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + event = Event.featureEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user, includeReason: false) } it("creates a feature event with matching data") { expect(event.kind) == Event.Kind.feature @@ -147,7 +147,7 @@ final class EventSpec: QuickSpec { } describe("debugEvent") { beforeEach { - event = Event.debugEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + event = Event.debugEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user, includeReason: false) } it("creates a debug event with matching data") { expect(event.kind) == Event.Kind.debug @@ -300,9 +300,10 @@ final class EventSpec: QuickSpec { config = LDConfig.stub featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool) } - context("without inlining user") { + context("without inlining user and with reason") { beforeEach { - event = Event.featureEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + let featureFlagWithReason = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool, includeEvaluationReason: true, includeTrackReason: true) + event = Event.featureEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlagWithReason, user: user, includeReason: true) config.inlineUserInEvents = false //Default value, here for clarity eventDictionary = event.dictionaryValue(config: config) } @@ -315,15 +316,16 @@ final class EventSpec: QuickSpec { expect(eventDictionary.eventVariation) == featureFlag.variation expect(eventDictionary.eventVersion) == featureFlag.flagVersion //Since feature flags include the flag version, it should be used. expect(eventDictionary.eventData).to(beNil()) + expect(AnyComparer.isEqual(eventDictionary.reason, to: DarklyServiceMock.Constants.reason)).to(beTrue()) } it("creates a dictionary with the user key only") { expect(eventDictionary.eventUserKey) == user.key expect(eventDictionary.eventUser).to(beNil()) } } - context("inlining user") { + context("inlining user and without reason") { beforeEach { - event = Event.featureEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + event = Event.featureEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user, includeReason: false) config.inlineUserInEvents = true eventDictionary = event.dictionaryValue(config: config) } @@ -336,6 +338,7 @@ final class EventSpec: QuickSpec { expect(eventDictionary.eventVariation) == featureFlag.variation expect(eventDictionary.eventVersion) == featureFlag.flagVersion //Since feature flags include the flag version, it should be used. expect(eventDictionary.eventData).to(beNil()) + expect(eventDictionary.reason).to(beNil()) } it("creates a dictionary with the full user") { expect(eventDictionary.eventUser).toNot(beNil()) @@ -359,8 +362,8 @@ final class EventSpec: QuickSpec { } context("omitting the flagVersion") { beforeEach { - featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool, includeFlagVersion: false) - event = Event.featureEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool, includeFlagVersion: false, includeEvaluationReason: true) + event = Event.featureEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user, includeReason: false) eventDictionary = event.dictionaryValue(config: config) } it("creates a dictionary with the version") { @@ -378,8 +381,8 @@ final class EventSpec: QuickSpec { } context("omitting flagVersion and version") { beforeEach { - featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool, includeVersion: false, includeFlagVersion: false) - event = Event.featureEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool, includeVersion: false, includeFlagVersion: false, includeEvaluationReason: true, includeTrackReason: false) + event = Event.featureEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user, includeReason: false) eventDictionary = event.dictionaryValue(config: config) } it("creates a dictionary without the version") { @@ -398,7 +401,7 @@ final class EventSpec: QuickSpec { context("without value or defaultValue") { beforeEach { featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.null) - event = Event.featureEvent(key: Constants.eventKey, value: nil, defaultValue: nil, featureFlag: featureFlag, user: user) + event = Event.featureEvent(key: Constants.eventKey, value: nil, defaultValue: nil, featureFlag: featureFlag, user: user, includeReason: false) eventDictionary = event.dictionaryValue(config: config) } it("creates a dictionary with matching non-user elements") { @@ -468,15 +471,17 @@ final class EventSpec: QuickSpec { let user = LDUser.stub() var event: Event! var eventDictionary: [String: Any]! + var metricValue: Double! context("custom event") { beforeEach { config = LDConfig.stub } + metricValue = 0.5 for eventData in CustomEvent.allData { context("with valid json data") { beforeEach { do { - event = try Event.customEvent(key: Constants.eventKey, user: user, data: eventData) + event = try Event.customEvent(key: Constants.eventKey, user: user, data: eventData, metricValue: metricValue) } catch JSONSerialization.JSONError.invalidJsonObject { fail("customEvent threw an invalidJsonObject exception") } catch { @@ -494,6 +499,7 @@ final class EventSpec: QuickSpec { expect(eventDictionary.eventVariation).to(beNil()) expect(eventDictionary.eventUserKey) == user.key expect(eventDictionary.eventUser).to(beNil()) + expect(eventDictionary.eventMetricValue) == metricValue } } } @@ -603,7 +609,7 @@ final class EventSpec: QuickSpec { } context("regardless of inlining user") { beforeEach { - event = Event.debugEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + event = Event.debugEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user, includeReason: false) } [true, false].forEach { (inlineUser) in it("creates a dictionary with matching non-user elements") { @@ -646,7 +652,7 @@ final class EventSpec: QuickSpec { context("omitting the flagVersion") { beforeEach { featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool, includeFlagVersion: false) - event = Event.debugEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + event = Event.debugEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user, includeReason: false) eventDictionary = event.dictionaryValue(config: config) } it("creates a dictionary with the version") { @@ -680,7 +686,7 @@ final class EventSpec: QuickSpec { context("omitting flagVersion and version") { beforeEach { featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool, includeVersion: false, includeFlagVersion: false) - event = Event.debugEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + event = Event.debugEvent(key: Constants.eventKey, value: true, defaultValue: false, featureFlag: featureFlag, user: user, includeReason: false) eventDictionary = event.dictionaryValue(config: config) } it("creates a dictionary without the version") { @@ -714,7 +720,7 @@ final class EventSpec: QuickSpec { context("without value or defaultValue") { beforeEach { featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.null) - event = Event.debugEvent(key: Constants.eventKey, value: nil, defaultValue: nil, featureFlag: featureFlag, user: user) + event = Event.debugEvent(key: Constants.eventKey, value: nil, defaultValue: nil, featureFlag: featureFlag, user: user, includeReason: false) eventDictionary = event.dictionaryValue(config: config) } it("creates a dictionary with matching non-user elements") { @@ -1279,6 +1285,9 @@ extension Dictionary where Key == String, Value == Any { var eventFeatures: [String: Any]? { return self[FlagRequestTracker.CodingKeys.features.rawValue] as? [String: Any] } + var eventMetricValue: Double? { + return self[Event.CodingKeys.metricValue.rawValue] as? Double + } } extension Array where Element == [String: Any] { @@ -1308,10 +1317,10 @@ extension Event { switch eventKind { case .feature: let featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool) - return Event.featureEvent(key: UUID().uuidString, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + return Event.featureEvent(key: UUID().uuidString, value: true, defaultValue: false, featureFlag: featureFlag, user: user, includeReason: false) case .debug: let featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool) - return Event.debugEvent(key: UUID().uuidString, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + return Event.debugEvent(key: UUID().uuidString, value: true, defaultValue: false, featureFlag: featureFlag, user: user, includeReason: false) case .identify: return Event.identifyEvent(user: user) case .custom: return (try? Event.customEvent(key: UUID().uuidString, user: user, data: ["custom": UUID().uuidString]))! case .summary: return Event.summaryEvent(flagRequestTracker: FlagRequestTracker.stub())! @@ -1319,7 +1328,7 @@ extension Event { } static func stubFeatureEvent(_ featureFlag: FeatureFlag, with user: LDUser) -> Event { - return Event.featureEvent(key: UUID().uuidString, value: true, defaultValue: false, featureFlag: featureFlag, user: user) + return Event.featureEvent(key: UUID().uuidString, value: true, defaultValue: false, featureFlag: featureFlag, user: user, includeReason: false) } static func stubEvents(eventCount: Int = Event.Kind.allKinds.count, for user: LDUser) -> [Event] { diff --git a/LaunchDarkly/LaunchDarklyTests/Models/FeatureFlag/FeatureFlagSpec.swift b/LaunchDarkly/LaunchDarklyTests/Models/FeatureFlag/FeatureFlagSpec.swift index 500f5fff..99241da7 100644 --- a/LaunchDarkly/LaunchDarklyTests/Models/FeatureFlag/FeatureFlagSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Models/FeatureFlag/FeatureFlagSpec.swift @@ -38,12 +38,14 @@ final class FeatureFlagSpec: QuickSpec { return flagVersion + 1 } let eventTrackingContext = EventTrackingContext.stub() + let reason = DarklyServiceMock.Constants.reason + let trackReason = false it("creates a feature flag with matching elements") { DarklyServiceMock.FlagKeys.knownFlags.forEach { (flagKey) in let value = DarklyServiceMock.FlagValues.value(from: flagKey) variation += 1 - featureFlag = FeatureFlag(flagKey: flagKey, value: value, variation: variation, version: version, flagVersion: flagVersion, eventTrackingContext: eventTrackingContext) + featureFlag = FeatureFlag(flagKey: flagKey, value: value, variation: variation, version: version, flagVersion: flagVersion, eventTrackingContext: eventTrackingContext, reason: reason, trackReason: trackReason) expect(featureFlag.flagKey) == flagKey expect(AnyComparer.isEqual(featureFlag.value, to: value, considerNilAndNullEqual: true)).to(beTrue()) @@ -56,7 +58,7 @@ final class FeatureFlagSpec: QuickSpec { } context("when elements don't exist") { beforeEach { - featureFlag = FeatureFlag(flagKey: DarklyServiceMock.FlagKeys.unknown, value: nil, variation: nil, version: nil, flagVersion: nil, eventTrackingContext: nil) + featureFlag = FeatureFlag(flagKey: DarklyServiceMock.FlagKeys.unknown, value: nil, variation: nil, version: nil, flagVersion: nil, eventTrackingContext: nil, reason: nil, trackReason: nil) } it("creates a feature flag with nil elements") { expect(featureFlag).toNot(beNil()) @@ -314,7 +316,9 @@ final class FeatureFlagSpec: QuickSpec { variation: DarklyServiceMock.Constants.variation, version: DarklyServiceMock.Constants.version, flagVersion: DarklyServiceMock.Constants.flagVersion, - eventTrackingContext: eventTrackingContext) + eventTrackingContext: eventTrackingContext, + reason: DarklyServiceMock.Constants.reason, + trackReason: false) reinflatedFlag = FeatureFlag(dictionary: featureFlag.dictionaryValue) } @@ -350,7 +354,7 @@ final class FeatureFlagSpec: QuickSpec { context("when keys differ") { it("returns false") { originalFlags.forEach { (_, originalFlag) in - otherFlag = FeatureFlag(flagKey: "dummyFlagKey", value: originalFlag.value, variation: originalFlag.variation, version: originalFlag.version, flagVersion: originalFlag.flagVersion, eventTrackingContext: originalFlag.eventTrackingContext) + otherFlag = FeatureFlag(flagKey: "dummyFlagKey", value: originalFlag.value, variation: originalFlag.variation, version: originalFlag.version, flagVersion: originalFlag.flagVersion, eventTrackingContext: originalFlag.eventTrackingContext, reason: DarklyServiceMock.Constants.reason, trackReason: false) expect(originalFlag == otherFlag).to(beFalse()) } @@ -440,7 +444,7 @@ final class FeatureFlagSpec: QuickSpec { } it("returns true") { originalFlags.forEach { (flagKey, originalFlag) in - otherFlag = FeatureFlag(flagKey: flagKey, value: originalFlag.value, variation: nil, version: nil, flagVersion: nil, eventTrackingContext: nil) + otherFlag = FeatureFlag(flagKey: flagKey, value: originalFlag.value, variation: nil, version: nil, flagVersion: nil, eventTrackingContext: nil, reason: nil, trackReason: false) expect(originalFlag == otherFlag).to(beTrue()) } @@ -487,7 +491,9 @@ final class FeatureFlagSpec: QuickSpec { variation: nil, version: originalFlag.version, flagVersion: originalFlag.flagVersion, - eventTrackingContext: originalFlag.eventTrackingContext) + eventTrackingContext: originalFlag.eventTrackingContext, + reason: DarklyServiceMock.Constants.reason, + trackReason: false) expect(originalFlag.matchesVariation(otherFlag)).to(beTrue()) } @@ -704,12 +710,14 @@ extension FeatureFlag { && flagVersion ?? FeatureFlag.nilPlaceholder == otherFlag.flagVersion ?? FeatureFlag.nilPlaceholder } - init(copying featureFlag: FeatureFlag, value: Any? = nil, variation: Int? = nil, version: Int? = nil, flagVersion: Int? = nil, eventTrackingContext: EventTrackingContext? = nil) { + init(copying featureFlag: FeatureFlag, value: Any? = nil, variation: Int? = nil, version: Int? = nil, flagVersion: Int? = nil, eventTrackingContext: EventTrackingContext? = nil, reason: Dictionary? = nil, trackReason: Bool? = nil) { self.init(flagKey: featureFlag.flagKey, value: value ?? featureFlag.value, variation: variation ?? featureFlag.variation, version: version ?? featureFlag.version, flagVersion: flagVersion ?? featureFlag.flagVersion, - eventTrackingContext: eventTrackingContext ?? featureFlag.eventTrackingContext) + eventTrackingContext: eventTrackingContext ?? featureFlag.eventTrackingContext, + reason: reason ?? featureFlag.reason, + trackReason: trackReason ?? featureFlag.trackReason) } } diff --git a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/CacheConverterSpec.swift b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/CacheConverterSpec.swift index 74c6e11d..36d95179 100644 --- a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/CacheConverterSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/CacheConverterSpec.swift @@ -53,12 +53,14 @@ final class CacheConverterSpec: QuickSpec { let age = Date().addingTimeInterval(cacheConverter.maxAge + 1.0) deprecatedCacheMock(for: deprecatedCacheData).retrieveFlagsReturnValue = (user.flagStore.featureFlags, age) switch deprecatedCacheData { + case .version6: + modelsToSearch.append(.version6) case .version5: - modelsToSearch.append(.version5) + modelsToSearch.append(contentsOf: [.version6, .version5]) case .version4: - modelsToSearch.append(contentsOf: [.version5, .version4]) + modelsToSearch.append(contentsOf: [.version6, .version5, .version4]) case .version3: - modelsToSearch.append(contentsOf: [.version5, .version4, .version3]) + modelsToSearch.append(contentsOf: [.version6, .version5, .version4, .version3]) case .version2: modelsToSearch.append(contentsOf: DeprecatedCacheModel.allCases) } @@ -255,6 +257,35 @@ final class CacheConverterSpec: QuickSpec { } } } + context("deprecated version 6 cache data exists") { + beforeEach { + testContext = TestContext(maxAge: Constants.maxAgeAlternate, createCacheData: false, deprecatedCacheData: .version6) + + testContext.cacheConverter.convertCacheData(for: testContext.user, and: testContext.config) + } + it("looks in the version 6 cache for data") { + DeprecatedCacheModel.allCases.forEach { (model) in + expect(testContext.deprecatedCacheMock(for: model).retrieveFlagsCallCount) == testContext.expectedretrieveFlagsCallCount(for: model) + } + } + it("creates current cache data from the deprecated version 6 cache data") { + expect(testContext.featureFlagCachingMock.storeFeatureFlagsCallCount) == 1 + expect(testContext.featureFlagCachingMock.storeFeatureFlagsReceivedArguments?.featureFlags) == + testContext.deprecatedCacheMock(for: .version6).retrieveFlagsReturnValue?.featureFlags + expect(testContext.featureFlagCachingMock.storeFeatureFlagsReceivedArguments?.user) == testContext.user + expect(testContext.featureFlagCachingMock.storeFeatureFlagsReceivedArguments?.mobileKey) == testContext.config.mobileKey + expect(testContext.featureFlagCachingMock.storeFeatureFlagsReceivedArguments?.lastUpdated) == + testContext.deprecatedCacheMock(for: .version6).retrieveFlagsReturnValue?.lastUpdated + expect(testContext.featureFlagCachingMock.storeFeatureFlagsReceivedArguments?.storeMode) == .sync + } + it("removes expired deprecated cache data") { + DeprecatedCacheModel.allCases.forEach { (model) in + expect(testContext.deprecatedCacheMock(for: model).removeDataCallCount) == 1 + expect(testContext.deprecatedCacheMock(for: model).removeDataReceivedExpirationDate? + .isWithin(Constants.acceptableInterval, of: testContext.expiredCacheThreshold)) == true + } + } + } context("deprecated version 5 cache data exists") { beforeEach { testContext = TestContext(maxAge: Constants.maxAgeAlternate, createCacheData: false, deprecatedCacheData: .version5) diff --git a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV2Spec.swift b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV2Spec.swift index 37890a36..e53fa427 100644 --- a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV2Spec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV2Spec.swift @@ -240,7 +240,7 @@ extension Dictionary where Key == LDFlagKey, Value == FeatureFlag { extension FeatureFlag { var modelV2FeatureFlag: FeatureFlag { - return FeatureFlag(flagKey: flagKey, value: value, variation: nil, version: nil, flagVersion: nil, eventTrackingContext: nil) + return FeatureFlag(flagKey: flagKey, value: value, variation: nil, version: nil, flagVersion: nil, eventTrackingContext: nil, reason: nil, trackReason: nil) } } diff --git a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV3Spec.swift b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV3Spec.swift index b885465a..4b6fea05 100644 --- a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV3Spec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV3Spec.swift @@ -240,7 +240,7 @@ extension Dictionary where Key == LDFlagKey, Value == FeatureFlag { extension FeatureFlag { var modelV3FeatureFlag: FeatureFlag { - return FeatureFlag(flagKey: flagKey, value: value, variation: nil, version: version, flagVersion: nil, eventTrackingContext: nil) + return FeatureFlag(flagKey: flagKey, value: value, variation: nil, version: version, flagVersion: nil, eventTrackingContext: nil, reason: nil, trackReason: nil) } } @@ -274,6 +274,7 @@ extension FeatureFlag { flagDictionary.removeValue(forKey: FeatureFlag.CodingKeys.flagVersion.rawValue) flagDictionary.removeValue(forKey: EventTrackingContext.CodingKeys.trackEvents.rawValue) flagDictionary.removeValue(forKey: EventTrackingContext.CodingKeys.debugEventsUntilDate.rawValue) + flagDictionary.removeValue(forKey: FeatureFlag.CodingKeys.reason.rawValue) return flagDictionary } } diff --git a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV4Spec.swift b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV4Spec.swift index af0e1df7..a56dbae8 100644 --- a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV4Spec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV4Spec.swift @@ -233,11 +233,17 @@ extension Dictionary where Key == LDFlagKey, Value == FeatureFlag { else { return nil } - return originalFeatureFlag + return originalFeatureFlag.modelV4FeatureFlag } } } +extension FeatureFlag { + var modelV4FeatureFlag: FeatureFlag { + return FeatureFlag(flagKey: flagKey, value: value, variation: variation, version: version, flagVersion: flagVersion, eventTrackingContext: eventTrackingContext, reason: nil, trackReason: nil) + } +} + // MARK: Dictionary value to cache extension LDUser { @@ -268,6 +274,8 @@ extension FeatureFlag { } var flagDictionary = dictionaryValue flagDictionary.removeValue(forKey: FeatureFlag.CodingKeys.flagKey.rawValue) + flagDictionary.removeValue(forKey: FeatureFlag.CodingKeys.reason.rawValue) + flagDictionary.removeValue(forKey: FeatureFlag.CodingKeys.trackReason.rawValue) return flagDictionary } } diff --git a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV5Spec.swift b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV5Spec.swift index f854de8e..e8f1583b 100644 --- a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV5Spec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV5Spec.swift @@ -250,11 +250,17 @@ extension Dictionary where Key == LDFlagKey, Value == FeatureFlag { else { return nil } - return originalFeatureFlag + return originalFeatureFlag.modelV5FeatureFlag } } } +extension FeatureFlag { + var modelV5FeatureFlag: FeatureFlag { + return FeatureFlag(flagKey: flagKey, value: value, variation: variation, version: version, flagVersion: flagVersion, eventTrackingContext: eventTrackingContext, reason: nil, trackReason: nil) + } +} + // MARK: Dictionary value to cache extension LDUser { @@ -285,6 +291,8 @@ extension FeatureFlag { } var flagDictionary = dictionaryValue flagDictionary.removeValue(forKey: FeatureFlag.CodingKeys.flagKey.rawValue) + flagDictionary.removeValue(forKey: FeatureFlag.CodingKeys.reason.rawValue) + flagDictionary.removeValue(forKey: FeatureFlag.CodingKeys.trackReason.rawValue) return flagDictionary } } diff --git a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV6Spec.swift b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV6Spec.swift new file mode 100644 index 00000000..db778179 --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/DeprecatedCacheModelV6Spec.swift @@ -0,0 +1,276 @@ +// +// DeprecatedCacheModelV6Spec.swift +// LaunchDarklyTests +// +// Created by Joe Cieslik on 11/25/19. +// Copyright © 2019 Catamorphic Co. All rights reserved. +// + +import Foundation +import Quick +import Nimble +@testable import LaunchDarkly + +final class DeprecatedCacheModelV6Spec: QuickSpec { + + struct Constants { + static let offsetInterval: TimeInterval = 0.1 + } + + struct TestContext { + var clientServiceFactoryMock = ClientServiceMockFactory() + var keyedValueCacheMock: KeyedValueCachingMock + var modelV6cache: DeprecatedCacheModelV6 + var users: [LDUser] + var userEnvironmentsCollection: [UserKey: CacheableUserEnvironmentFlags] + var uncachedUser: LDUser + var mobileKeys: [MobileKey] + var uncachedMobileKey: MobileKey + var lastUpdatedDates: [UserKey: Date] { + return userEnvironmentsCollection.compactMapValues { (cacheableUserFlags) in + return cacheableUserFlags.lastUpdated + } + } + var sortedLastUpdatedDates: [(userKey: UserKey, lastUpdated: Date)] { + return lastUpdatedDates.map { (userKey, lastUpdated) in + return (userKey, lastUpdated) + }.sorted { (tuple1, tuple2) in + return tuple1.lastUpdated.isEarlierThan(tuple2.lastUpdated) + } + } + var userKeys: [UserKey] { + return users.map { (user) in + return user.key + } + } + + init(userCount: Int = 0) { + keyedValueCacheMock = clientServiceFactoryMock.makeKeyedValueCache() as! KeyedValueCachingMock + modelV6cache = DeprecatedCacheModelV6(keyedValueCache: keyedValueCacheMock) + + (users, userEnvironmentsCollection, mobileKeys) = CacheableUserEnvironmentFlags.stubCollection(userCount: userCount) + + uncachedUser = LDUser.stub() + uncachedMobileKey = UUID().uuidString + + keyedValueCacheMock.dictionaryReturnValue = modelV6Dictionary(for: users, and: userEnvironmentsCollection, mobileKeys: mobileKeys) + } + + func featureFlags(for userKey: UserKey, and mobileKey: MobileKey) -> [LDFlagKey: FeatureFlag]? { + return userEnvironmentsCollection[userKey]?.environmentFlags[mobileKey]?.featureFlags.modelV6flagCollection + } + + func modelV6Dictionary(for users: [LDUser], and userEnvironmentsCollection: [UserKey: CacheableUserEnvironmentFlags], mobileKeys: [MobileKey]) -> [UserKey: Any]? { + guard !users.isEmpty + else { + return nil + } + + var cacheDictionary = [UserKey: [String: Any]]() + users.forEach { (user) in + guard let userEnvironment = userEnvironmentsCollection[user.key] + else { + return + } + var environmentsDictionary = [MobileKey: Any]() + let lastUpdated = userEnvironmentsCollection[user.key]?.lastUpdated + mobileKeys.forEach { (mobileKey) in + guard let featureFlags = userEnvironment.environmentFlags[mobileKey]?.featureFlags + else { + return + } + environmentsDictionary[mobileKey] = user.modelV6DictionaryValue(including: featureFlags, using: lastUpdated) + } + cacheDictionary[user.key] = [CacheableEnvironmentFlags.CodingKeys.userKey.rawValue: user.key, + DeprecatedCacheModelV6.CacheKeys.environments: environmentsDictionary] + } + return cacheDictionary + } + + func expiredUserKeys(for expirationDate: Date) -> [UserKey] { + return sortedLastUpdatedDates.compactMap { (tuple) in + guard tuple.lastUpdated.isEarlierThan(expirationDate) + else { + return nil + } + return tuple.userKey + } + } + } + + override func spec() { + initSpec() + retrieveFlagsSpec() + removeDataSpec() + } + + private func initSpec() { + var testContext: TestContext! + describe("init") { + context("with keyed value cache") { + beforeEach { + testContext = TestContext() + } + it("creates a model version 6 cache with the keyed value cache") { + expect(testContext.modelV6cache.keyedValueCache) === testContext.keyedValueCacheMock + } + } + } + } + + private func retrieveFlagsSpec() { + var testContext: TestContext! + var cachedData: (featureFlags: [LDFlagKey: FeatureFlag]?, lastUpdated: Date?)! + describe("retrieveFlags") { + context("when no cached data exists") { + beforeEach { + testContext = TestContext() + + cachedData = testContext.modelV6cache.retrieveFlags(for: testContext.uncachedUser.key, and: testContext.uncachedMobileKey) + } + it("returns nil values") { + expect(cachedData.featureFlags).to(beNil()) + expect(cachedData.lastUpdated).to(beNil()) + } + } + context("when cached data exists") { + context("and a cached user is requested") { + beforeEach { + testContext = TestContext(userCount: UserEnvironmentFlagCache.Constants.maxCachedUsers) + } + it("retrieves the cached data") { + testContext.users.forEach { (user) in + let expectedLastUpdated = testContext.userEnvironmentsCollection.lastUpdated(forKey: user.key)?.stringEquivalentDate + testContext.mobileKeys.forEach { (mobileKey) in + let expectedFlags = testContext.featureFlags(for: user.key, and: mobileKey) + cachedData = testContext.modelV6cache.retrieveFlags(for: user.key, and: mobileKey) + expect(cachedData.featureFlags) == expectedFlags + expect(cachedData.lastUpdated) == expectedLastUpdated + } + } + } + } + context("and an uncached mobileKey is requested") { + beforeEach { + testContext = TestContext(userCount: UserEnvironmentFlagCache.Constants.maxCachedUsers) + + cachedData = testContext.modelV6cache.retrieveFlags(for: testContext.users.first!.key, and: testContext.uncachedMobileKey) + } + it("returns nil values") { + expect(cachedData.featureFlags).to(beNil()) + expect(cachedData.lastUpdated).to(beNil()) + } + } + context("and an uncached user is requested") { + beforeEach { + testContext = TestContext(userCount: UserEnvironmentFlagCache.Constants.maxCachedUsers) + + cachedData = testContext.modelV6cache.retrieveFlags(for: testContext.uncachedUser.key, and: testContext.mobileKeys.first!) + } + it("returns nil values") { + expect(cachedData.featureFlags).to(beNil()) + expect(cachedData.lastUpdated).to(beNil()) + } + } + } + } + } + + private func removeDataSpec() { + var testContext: TestContext! + var expirationDate: Date! + describe("removeData") { + context("no modelV6 cached data expired") { + beforeEach { + testContext = TestContext(userCount: UserEnvironmentFlagCache.Constants.maxCachedUsers) + let oldestLastUpdatedDate = testContext.sortedLastUpdatedDates.first! + expirationDate = oldestLastUpdatedDate.lastUpdated.addingTimeInterval(-Constants.offsetInterval) + + testContext.modelV6cache.removeData(olderThan: expirationDate) + } + it("does not remove any modelV6 cached data") { + expect(testContext.keyedValueCacheMock.setCallCount) == 0 + } + } + context("some modelV6 cached data expired") { + beforeEach { + testContext = TestContext(userCount: UserEnvironmentFlagCache.Constants.maxCachedUsers) + let selectedLastUpdatedDate = testContext.sortedLastUpdatedDates[testContext.users.count / 2] + expirationDate = selectedLastUpdatedDate.lastUpdated.addingTimeInterval(-Constants.offsetInterval) + + testContext.modelV6cache.removeData(olderThan: expirationDate) + } + it("removes expired modelV6 cached data") { + expect(testContext.keyedValueCacheMock.setCallCount) == 1 + expect(testContext.keyedValueCacheMock.setReceivedArguments?.forKey) == DeprecatedCacheModelV6.CacheKeys.userEnvironments + let recachedData = testContext.keyedValueCacheMock.setReceivedArguments?.value as? [String: Any] + let expiredUserKeys = testContext.expiredUserKeys(for: expirationDate) + testContext.userKeys.forEach { (userKey) in + expect(recachedData?.keys.contains(userKey)) == !expiredUserKeys.contains(userKey) + } + } + } + context("all modelV6 cached data expired") { + beforeEach { + testContext = TestContext(userCount: UserEnvironmentFlagCache.Constants.maxCachedUsers) + let newestLastUpdatedDate = testContext.sortedLastUpdatedDates.last! + expirationDate = newestLastUpdatedDate.lastUpdated.addingTimeInterval(Constants.offsetInterval) + + testContext.modelV6cache.removeData(olderThan: expirationDate) + } + it("removes all modelV6 cached data") { + expect(testContext.keyedValueCacheMock.removeObjectCallCount) == 1 + expect(testContext.keyedValueCacheMock.removeObjectReceivedForKey) == DeprecatedCacheModelV6.CacheKeys.userEnvironments + } + } + context("no modelV6 cached data exists") { + beforeEach { + testContext = TestContext(userCount: UserEnvironmentFlagCache.Constants.maxCachedUsers) + let newestLastUpdatedDate = testContext.sortedLastUpdatedDates.last! + expirationDate = newestLastUpdatedDate.lastUpdated.addingTimeInterval(Constants.offsetInterval) + testContext.keyedValueCacheMock.dictionaryReturnValue = nil //mock simulates no modelV6 cached data + + testContext.modelV6cache.removeData(olderThan: expirationDate) + } + it("makes no cached data changes") { + expect(testContext.keyedValueCacheMock.setCallCount) == 0 + } + } + } + } + +} + +extension Dictionary where Key == LDFlagKey, Value == FeatureFlag { + var modelV6flagCollection: [LDFlagKey: FeatureFlag] { + return compactMapValues { (originalFeatureFlag) in + guard originalFeatureFlag.value != nil + else { + return nil + } + return originalFeatureFlag + } + } +} + +extension LDUser { + func modelV6DictionaryValue(including featureFlags: [LDFlagKey: FeatureFlag], using lastUpdated: Date?) -> [String: Any] { + var userDictionary = dictionaryValueWithAllAttributes(includeFlagConfig: false) + userDictionary.setLastUpdated(lastUpdated) + userDictionary[LDUser.CodingKeys.config.rawValue] = featureFlags.compactMapValues { (featureFlag) in + return featureFlag.modelV6dictionaryValue + } + + return userDictionary + } +} + +extension FeatureFlag { + var modelV6dictionaryValue: [String: Any]? { + guard value != nil + else { + return nil + } + return dictionaryValue + } +} diff --git a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/UserEnvironmentFlagCacheSpec.swift b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/UserEnvironmentFlagCacheSpec.swift index 50eff54d..0c728f31 100644 --- a/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/UserEnvironmentFlagCacheSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Service Objects/Cache/UserEnvironmentFlagCacheSpec.swift @@ -597,7 +597,9 @@ extension FeatureFlag { variation: DarklyServiceMock.Constants.variation, version: DarklyServiceMock.Constants.version, flagVersion: DarklyServiceMock.Constants.flagVersion, - eventTrackingContext: EventTrackingContext.stub()) + eventTrackingContext: EventTrackingContext.stub(), + reason: DarklyServiceMock.Constants.reason, + trackReason: false) } } diff --git a/LaunchDarkly/LaunchDarklyTests/Service Objects/EventReporterSpec.swift b/LaunchDarkly/LaunchDarklyTests/Service Objects/EventReporterSpec.swift index 2dc76915..769db6ca 100644 --- a/LaunchDarkly/LaunchDarklyTests/Service Objects/EventReporterSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Service Objects/EventReporterSpec.swift @@ -38,6 +38,8 @@ final class EventReporterSpec: QuickSpec { var flagKey: LDFlagKey! var eventTrackingContext: EventTrackingContext! var featureFlag: FeatureFlag! + var featureFlagWithReason: FeatureFlag! + var featureFlagWithReasonAndTrackReason: FeatureFlag! var eventStubResponseDate: Date? var flagRequestTracker: FlagRequestTracker? var reportersTracker: FlagRequestTracker? { @@ -92,6 +94,8 @@ final class EventReporterSpec: QuickSpec { eventTrackingContext = EventTrackingContext(trackEvents: self.eventTrackingContext?.trackEvents ?? false, debugEventsUntilDate: debugEventsUntilDate) } featureFlag = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool, eventTrackingContext: eventTrackingContext) + featureFlagWithReason = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool, eventTrackingContext: eventTrackingContext, includeEvaluationReason: true) + featureFlagWithReasonAndTrackReason = DarklyServiceMock.Constants.stubFeatureFlag(for: DarklyServiceMock.FlagKeys.bool, eventTrackingContext: eventTrackingContext, includeEvaluationReason: true, includeTrackReason: true) self.flagRequestCount = flagRequestCount } @@ -558,7 +562,7 @@ final class EventReporterSpec: QuickSpec { private func recordFeatureAndDebugEventsSpec() { var testContext: TestContext! context("record feature and debug events") { - context("when trackEvents is on") { + context("when trackEvents is on and a reason is present") { beforeEach { testContext = TestContext(trackEvents: true) @@ -566,8 +570,9 @@ final class EventReporterSpec: QuickSpec { testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, - featureFlag: testContext.featureFlag, + featureFlag: testContext.featureFlagWithReason, user: testContext.user, + includeReason: true, completion: done) } } @@ -580,7 +585,35 @@ final class EventReporterSpec: QuickSpec { let flagValueCounter = testContext.flagValueCounter(for: testContext.flagKey, and: testContext.featureFlag) expect(flagValueCounter).toNot(beNil()) expect(AnyComparer.isEqual(flagValueCounter?.reportedValue, to: testContext.featureFlag.value)).to(beTrue()) - expect(flagValueCounter?.featureFlag) == testContext.featureFlag + expect(flagValueCounter?.featureFlag) == testContext.featureFlagWithReason + expect(flagValueCounter?.isKnown) == true + expect(flagValueCounter?.count) == 1 + } + } + context("when a reason is present and reason is false but trackReason is true") { + beforeEach { + testContext = TestContext(trackEvents: true) + + waitUntil { done in + testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, + value: testContext.featureFlag.value!, + defaultValue: Constants.defaultValue, + featureFlag: testContext.featureFlagWithReasonAndTrackReason, + user: testContext.user, + includeReason: false, + completion: done) + } + } + it("records a feature event") { + expect(testContext.eventReporter.eventStore.count) == 1 + expect(testContext.eventReporter.eventStoreKeys.contains(testContext.flagKey)).to(beTrue()) + expect(testContext.eventReporter.eventStoreKinds.contains(.feature)).to(beTrue()) + } + it("tracks the flag request") { + let flagValueCounter = testContext.flagValueCounter(for: testContext.flagKey, and: testContext.featureFlag) + expect(flagValueCounter).toNot(beNil()) + expect(AnyComparer.isEqual(flagValueCounter?.reportedValue, to: testContext.featureFlag.value)).to(beTrue()) + expect(flagValueCounter?.featureFlag) == testContext.featureFlagWithReasonAndTrackReason expect(flagValueCounter?.isKnown) == true expect(flagValueCounter?.count) == 1 } @@ -595,6 +628,7 @@ final class EventReporterSpec: QuickSpec { defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, + includeReason: false, completion: done) } } @@ -617,7 +651,7 @@ final class EventReporterSpec: QuickSpec { testContext = TestContext(lastEventResponseDate: Date(), trackEvents: false, debugEventsUntilDate: Date().addingTimeInterval(TimeInterval.oneSecond)) waitUntil { done in - testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, completion: done) + testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, includeReason: false, completion: done) } } it("records a debug event") { @@ -639,7 +673,7 @@ final class EventReporterSpec: QuickSpec { testContext = TestContext(lastEventResponseDate: Date(), trackEvents: false, debugEventsUntilDate: Date().addingTimeInterval(-TimeInterval.oneSecond)) waitUntil { done in - testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, completion: done) + testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, includeReason: false, completion: done) } } it("does not record a debug event") { @@ -661,7 +695,7 @@ final class EventReporterSpec: QuickSpec { testContext = TestContext(lastEventResponseDate: nil, trackEvents: false, debugEventsUntilDate: Date().addingTimeInterval(TimeInterval.oneSecond)) waitUntil { done in - testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, completion: done) + testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, includeReason: false, completion: done) } } it("records a debug event") { @@ -683,7 +717,7 @@ final class EventReporterSpec: QuickSpec { testContext = TestContext(lastEventResponseDate: nil, trackEvents: false, debugEventsUntilDate: Date().addingTimeInterval(-TimeInterval.oneSecond)) waitUntil { done in - testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, completion: done) + testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, includeReason: false, completion: done) } } it("does not record a debug event") { @@ -705,7 +739,7 @@ final class EventReporterSpec: QuickSpec { testContext = TestContext(lastEventResponseDate: Date(), trackEvents: true, debugEventsUntilDate: Date().addingTimeInterval(TimeInterval.oneSecond)) waitUntil { done in - testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, completion: done) + testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, includeReason: false, completion: done) } } it("records a feature and debug event") { @@ -724,12 +758,38 @@ final class EventReporterSpec: QuickSpec { expect(flagValueCounter?.count) == 1 } } + context("when both trackEvents is true, debugEventsUntilDate is later than lastEventResponseDate, reason is false, and track reason is true") { + beforeEach { + testContext = TestContext(lastEventResponseDate: Date(), trackEvents: true, debugEventsUntilDate: Date().addingTimeInterval(TimeInterval.oneSecond)) + + waitUntil { done in + testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlagWithReasonAndTrackReason, user: testContext.user, includeReason: false, completion: done) + } + } + it("records a feature and debug event") { + expect(testContext.eventReporter.eventStore.count == 2).to(beTrue()) + expect(testContext.eventReporter.eventStore[0]["reason"] as? Dictionary == DarklyServiceMock.Constants.reason).to(beTrue()) + expect(testContext.eventReporter.eventStore[1]["reason"] as? Dictionary == DarklyServiceMock.Constants.reason).to(beTrue()) + expect(testContext.eventReporter.eventStoreKeys.filter { (eventKey) in + eventKey == testContext.flagKey + }.count == 2).to(beTrue()) + expect(Set(testContext.eventReporter.eventStore.eventKinds)).to(equal(Set([.feature, .debug]))) + } + it("tracks the flag request") { + let flagValueCounter = testContext.flagValueCounter(for: testContext.flagKey, and: testContext.featureFlag) + expect(flagValueCounter).toNot(beNil()) + expect(AnyComparer.isEqual(flagValueCounter?.reportedValue, to: testContext.featureFlag.value)).to(beTrue()) + expect(flagValueCounter?.featureFlag) == testContext.featureFlagWithReasonAndTrackReason + expect(flagValueCounter?.isKnown) == true + expect(flagValueCounter?.count) == 1 + } + } context("when debugEventsUntilDate is nil") { beforeEach { testContext = TestContext(lastEventResponseDate: Date(), trackEvents: false, debugEventsUntilDate: nil) waitUntil { done in - testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, completion: done) + testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value!, defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, includeReason: false, completion: done) } } it("does not record an event") { @@ -754,6 +814,7 @@ final class EventReporterSpec: QuickSpec { defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, + includeReason: false, completion: done) } } @@ -781,6 +842,7 @@ final class EventReporterSpec: QuickSpec { defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, + includeReason: false, completion: index == testContext.flagRequestCount ? done : nil) } } @@ -818,6 +880,7 @@ final class EventReporterSpec: QuickSpec { defaultValue: Constants.defaultValue, featureFlag: testContext.featureFlag, user: testContext.user, + includeReason: false, completion: recordFlagEvaluationCompletion) } } @@ -843,7 +906,7 @@ final class EventReporterSpec: QuickSpec { testContext = TestContext() waitUntil { done in - testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value, defaultValue: testContext.featureFlag.value, featureFlag: testContext.featureFlag, user: testContext.user, completion: done) + testContext.eventReporter.recordFlagEvaluationEvents(flagKey: testContext.flagKey, value: testContext.featureFlag.value, defaultValue: testContext.featureFlag.value, featureFlag: testContext.featureFlag, user: testContext.user, includeReason: false, completion: done) } } it("tracks flag requests") { diff --git a/README.md b/README.md index 3a9a70b1..4f3e054c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ $ gem install cocoapods ```ruby use_frameworks! target 'YourTargetName' do - pod 'LaunchDarkly', '4.2.1' + pod 'LaunchDarkly', '4.3.0' end ``` @@ -70,7 +70,7 @@ $ brew install carthage To integrate LaunchDarkly into your Xcode project using Carthage, specify it in your `Cartfile`: ```ogdl -github "launchdarkly/ios-client-sdk" "4.2.1" +github "launchdarkly/ios-client-sdk" "4.3.0" ``` Run `carthage update` to build the framework. Optionally, specify the `--platform` to build only the frameworks that support your platform(s).