Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for external cache storage #412

Open
wants to merge 21 commits into
base: v9
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion LaunchDarkly.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@
3D3AB9462A4F16FE003AECF1 /* ReportingConsts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */; };
3D3AB9482A570F3A003AECF1 /* ModifierSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3AB9472A570F3A003AECF1 /* ModifierSpec.swift */; };
3D9A12582A73236800698B8D /* UtilSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9A12572A73236800698B8D /* UtilSpec.swift */; };
48761C9F2CCA310400561EC4 /* CwlPreconditionTesting in Frameworks */ = {isa = PBXBuildFile; productRef = A3F4A4802CC2F640006EF480 /* CwlPreconditionTesting */; };
48761CA32CCA31B100561EC4 /* LDFileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA02CCA31B100561EC4 /* LDFileCache.swift */; };
48761CA42CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */; };
48761CA52CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */; };
48761CA62CCA31B100561EC4 /* LDFileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA02CCA31B100561EC4 /* LDFileCache.swift */; };
48761CA72CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */; };
48761CA82CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */; };
48761CA92CCA31B100561EC4 /* LDFileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA02CCA31B100561EC4 /* LDFileCache.swift */; };
48761CAA2CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */; };
48761CAB2CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */; };
48761CAC2CCA31B100561EC4 /* LDFileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA02CCA31B100561EC4 /* LDFileCache.swift */; };
48761CAD2CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */; };
48761CAE2CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */; };
48761CB02CCA322700561EC4 /* KeyedValueCachingSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48761CAF2CCA322700561EC4 /* KeyedValueCachingSpec.swift */; };
830BF933202D188E006DF9B1 /* HTTPURLRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830BF932202D188E006DF9B1 /* HTTPURLRequest.swift */; };
830DB3AC22380A3E00D65D25 /* HTTPHeadersSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830DB3AB22380A3E00D65D25 /* HTTPHeadersSpec.swift */; };
830DB3AE2239B54900D65D25 /* URLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830DB3AD2239B54900D65D25 /* URLResponse.swift */; };
Expand Down Expand Up @@ -406,6 +420,10 @@
3D3AB9422A4F16FE003AECF1 /* ReportingConsts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportingConsts.swift; sourceTree = "<group>"; };
3D3AB9472A570F3A003AECF1 /* ModifierSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifierSpec.swift; sourceTree = "<group>"; };
3D9A12572A73236800698B8D /* UtilSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilSpec.swift; sourceTree = "<group>"; };
48761CA02CCA31B100561EC4 /* LDFileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDFileCache.swift; sourceTree = "<group>"; };
48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDInMemoryCache.swift; sourceTree = "<group>"; };
48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsCache.swift; sourceTree = "<group>"; };
48761CAF2CCA322700561EC4 /* KeyedValueCachingSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyedValueCachingSpec.swift; sourceTree = "<group>"; };
830BF932202D188E006DF9B1 /* HTTPURLRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPURLRequest.swift; sourceTree = "<group>"; };
830DB3AB22380A3E00D65D25 /* HTTPHeadersSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeadersSpec.swift; sourceTree = "<group>"; };
830DB3AD2239B54900D65D25 /* URLResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLResponse.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -560,10 +578,10 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
48761C9F2CCA310400561EC4 /* CwlPreconditionTesting in Frameworks */,
B4903D9E24BD61EF00F087C4 /* Quick in Frameworks */,
B4903D9B24BD61D000F087C4 /* Nimble in Frameworks */,
B4903D9824BD61B200F087C4 /* OHHTTPStubsSwift in Frameworks */,
A3F4A4812CC2F640006EF480 /* CwlPreconditionTesting in Frameworks */,
8354EFCC1F22491C00C05156 /* LaunchDarkly.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -636,6 +654,9 @@
C408884623033B3600420721 /* ConnectionInformationStore.swift */,
83D559731FD87CC9002D10C8 /* KeyedValueCache.swift */,
8354AC6F2243166900CDE602 /* FeatureFlagCache.swift */,
48761CA02CCA31B100561EC4 /* LDFileCache.swift */,
48761CA12CCA31B100561EC4 /* LDInMemoryCache.swift */,
48761CA22CCA31B100561EC4 /* UserDefaultsCache.swift */,
832D68A1224A38FC005F052A /* CacheConverter.swift */,
B4C9D4322489C8FD004A9B03 /* DiagnosticCache.swift */,
);
Expand All @@ -645,6 +666,7 @@
8354AC75224316C700CDE602 /* Cache */ = {
isa = PBXGroup;
children = (
48761CAF2CCA322700561EC4 /* KeyedValueCachingSpec.swift */,
8354AC76224316F800CDE602 /* FeatureFlagCacheSpec.swift */,
832D68AB224B3321005F052A /* CacheConverterSpec.swift */,
B40B419B249ADA6B00CD0726 /* DiagnosticCacheSpec.swift */,
Expand Down Expand Up @@ -1302,6 +1324,9 @@
83906A7B21190B7700D7D3C5 /* DateFormatter.swift in Sources */,
831188502113ADEF00D77CB5 /* EnvironmentReporter.swift in Sources */,
831188682113AE5600D77CB5 /* ObjcLDClient.swift in Sources */,
48761CAC2CCA31B100561EC4 /* LDFileCache.swift in Sources */,
48761CAD2CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */,
48761CAE2CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */,
831188572113AE0B00D77CB5 /* FlagChangeNotifier.swift in Sources */,
8311884D2113ADE200D77CB5 /* FlagsUnchangedObserver.swift in Sources */,
8311885F2113AE2D00D77CB5 /* HTTPURLRequest.swift in Sources */,
Expand Down Expand Up @@ -1413,6 +1438,9 @@
831EF35920655E730001C643 /* Log.swift in Sources */,
A358D6E42A4DE98300270C60 /* MacOSEnvironmentReporter.swift in Sources */,
831EF35A20655E730001C643 /* HTTPHeaders.swift in Sources */,
48761CA62CCA31B100561EC4 /* LDFileCache.swift in Sources */,
48761CA72CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */,
48761CA82CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */,
831EF35B20655E730001C643 /* DarklyService.swift in Sources */,
831EF35C20655E730001C643 /* HTTPURLResponse.swift in Sources */,
C443A40723145FEE00145710 /* ConnectionInformationStore.swift in Sources */,
Expand Down Expand Up @@ -1447,6 +1475,9 @@
831D8B6F1F71532300ED65E8 /* HTTPHeaders.swift in Sources */,
835E1D3F1F63450A00184DB4 /* ObjcLDClient.swift in Sources */,
83EBCBB320DABE1B003A7142 /* FlagRequestTracker.swift in Sources */,
48761CA92CCA31B100561EC4 /* LDFileCache.swift in Sources */,
48761CAA2CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */,
48761CAB2CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */,
837EF3742059C237009D628A /* Log.swift in Sources */,
83FEF8DD1F266742001CF12C /* FlagSynchronizer.swift in Sources */,
830BF933202D188E006DF9B1 /* HTTPURLRequest.swift in Sources */,
Expand Down Expand Up @@ -1529,6 +1560,7 @@
A3BA7D042BD2BD620000DB28 /* TestContext.swift in Sources */,
83B6E3F1222EFA3800FF2A6A /* ThreadSpec.swift in Sources */,
831AAE3020A9E75D00B46DBA /* ThrottlerSpec.swift in Sources */,
48761CB02CCA322700561EC4 /* KeyedValueCachingSpec.swift in Sources */,
832D68AC224B3321005F052A /* CacheConverterSpec.swift in Sources */,
838F96741FB9F024009CFC45 /* LDClientSpec.swift in Sources */,
83A0E6B1203B557F00224298 /* FeatureFlagSpec.swift in Sources */,
Expand Down Expand Up @@ -1573,6 +1605,9 @@
83D9EC752062DEAB004D7FA6 /* LDCommon.swift in Sources */,
83D9EC762062DEAB004D7FA6 /* LDConfig.swift in Sources */,
83EBCBB420DABE1B003A7142 /* FlagRequestTracker.swift in Sources */,
48761CA32CCA31B100561EC4 /* LDFileCache.swift in Sources */,
48761CA42CCA31B100561EC4 /* UserDefaultsCache.swift in Sources */,
48761CA52CCA31B100561EC4 /* LDInMemoryCache.swift in Sources */,
83D9EC772062DEAB004D7FA6 /* LDClient.swift in Sources */,
B4C9D4342489C8FD004A9B03 /* DiagnosticCache.swift in Sources */,
83D9EC7C2062DEAB004D7FA6 /* FeatureFlag.swift in Sources */,
Expand Down
20 changes: 9 additions & 11 deletions LaunchDarkly/GeneratedCode/mocks.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ import LDSwiftEventSource
// MARK: - CacheConvertingMock
final class CacheConvertingMock: CacheConverting {

var migrateStorageCallCount = 0
var migrateStorageCallback: (() throws -> Void)?
var migrateStorageReceivedArguments: (serviceFactory: ClientServiceCreating, keysToMigrate: [MobileKey], oldCache: LDConfig.CacheFactory)?
func migrateStorage(serviceFactory: ClientServiceCreating, keysToMigrate: [MobileKey], from oldCache: @escaping LDConfig.CacheFactory) {
migrateStorageCallCount += 1
migrateStorageReceivedArguments = (serviceFactory: serviceFactory, keysToMigrate: keysToMigrate, oldCache: oldCache)
try! migrateStorageCallback?()
}

var convertCacheDataCallCount = 0
var convertCacheDataCallback: (() throws -> Void)?
var convertCacheDataReceivedArguments: (serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey], maxCachedContexts: Int)?
Expand Down Expand Up @@ -353,17 +362,6 @@ final class KeyedValueCachingMock: KeyedValueCaching {
return dataReturnValue
}

var dictionaryCallCount = 0
var dictionaryCallback: (() throws -> Void)?
var dictionaryReceivedForKey: String?
var dictionaryReturnValue: [String: Any]?
func dictionary(forKey: String) -> [String: Any]? {
dictionaryCallCount += 1
dictionaryReceivedForKey = forKey
try! dictionaryCallback?()
return dictionaryReturnValue
}

var removeObjectCallCount = 0
var removeObjectCallback: (() throws -> Void)?
var removeObjectReceivedForKey: String?
Expand Down
6 changes: 4 additions & 2 deletions LaunchDarkly/LaunchDarkly/LDClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -752,10 +752,12 @@ public class LDClient {

os_log("%s LDClient starting", log: config.logger, type: .debug, typeName(and: #function))

let serviceFactory = serviceFactory ?? ClientServiceFactory(logger: config.logger)
let serviceFactory = serviceFactory ?? ClientServiceFactory(logger: config.logger, cacheFactory: config.cacheFactory)
var keys = [config.mobileKey]
keys.append(contentsOf: config.getSecondaryMobileKeys().values)
serviceFactory.makeCacheConverter().convertCacheData(serviceFactory: serviceFactory, keysToConvert: keys, maxCachedContexts: config.maxCachedContexts)
let cacheConverter = serviceFactory.makeCacheConverter()
cacheConverter.migrateStorage(serviceFactory: serviceFactory, keysToMigrate: keys, from: LDConfig.Defaults.cacheFactory)
cacheConverter.convertCacheData(serviceFactory: serviceFactory, keysToConvert: keys, maxCachedContexts: config.maxCachedContexts)
Comment on lines +759 to +760
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With caching implementations being as wildly different as they are, it seem like the convertCacheData should be implemented alongside the cache implementation.

So I think the cache prototype probably needs a method to retrieve an optional converter. If provided, then we pass in the list of mobile keys, and the max cached contexts option. From there, the implementation is cache specific.

We should also conversion migration and conversion as the same concept. If someone wants to migrate between two completely different storage technologies, they can write a custom cacher to handle that. Seems like it would be straight forward enough.

var mobileKeys = config.getSecondaryMobileKeys()
var internalCount = 0
let completionCheck = {
Expand Down
8 changes: 8 additions & 0 deletions LaunchDarkly/LaunchDarkly/Models/LDConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,11 @@ public struct LDConfig {
/// The default logger for the SDK. Can be overridden to provide customization.
static let logger: OSLog = OSLog(subsystem: "com.launchdarkly", category: "ios-client-sdk")

/// The default cache for feature flags is UserDefaults
static let cacheFactory: CacheFactory = { cacheKey, _ in
UserDefaults(suiteName: cacheKey)!
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize we were previously forcing the unwrap here and you just moved this bit of code.

Now that we have an in memory cache that can't fail, what about having this fall back to that if this instantiation fails? Then we don't have to worry about the force unwrap anymore.

}

/// The default behavior for event payload compression.
static let enableCompression: Bool = false
}
Expand Down Expand Up @@ -427,6 +432,9 @@ public struct LDConfig {
/// Configure the logger that will be used by the rest of the SDK.
public var logger: OSLog = Defaults.logger

/// Configure the persistent storage for caching flags locally
public var cacheFactory: CacheFactory = Defaults.cacheFactory

/// LaunchDarkly defined minima for selected configurable items
public let minima: Minima

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

// sourcery: autoMockable
protocol CacheConverting {
func migrateStorage(serviceFactory: ClientServiceCreating, keysToMigrate: [MobileKey], from oldCache: @escaping LDConfig.CacheFactory)
func convertCacheData(serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey], maxCachedContexts: Int)
}

Expand Down Expand Up @@ -40,6 +41,20 @@ final class CacheConverter: CacheConverting {

init() { }

func migrateStorage(serviceFactory: ClientServiceCreating, keysToMigrate: [MobileKey], from oldCacheFactory: @escaping LDConfig.CacheFactory) {
keysToMigrate.forEach { mobileKey in
let cacheKey = mobileKey.cacheKey()
let newCache = serviceFactory.makeKeyedValueCache(cacheKey: cacheKey)
guard newCache.keys().isEmpty else { return }
let oldCache = oldCacheFactory(cacheKey, .disabled)
oldCache.keys().forEach { key in
if let data = oldCache.data(forKey: key) {
newCache.set(data, forKey: key)
}
}
}
}

func convertCacheData(serviceFactory: ClientServiceCreating, keysToConvert: [MobileKey], maxCachedContexts: Int) {
// Remove V5 cache data
let standardDefaults = serviceFactory.makeKeyedValueCache(cacheKey: nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,24 @@ protocol FeatureFlagCaching {
func saveCachedData(_ storedItems: StoredItems, cacheKey: String, contextHash: String, lastUpdated: Date, etag: String?)
}

extension MobileKey {
func cacheKey() -> String {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about having the cache define it's own key? I don't see any reason why completely different implementations need to use the same format as this one.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't consider this as an important thing to add (trying to make the PR smaller), but this certainly can be done. Ideally, a wider refactoring is needed here. The parameter name cacheKey is confusing because it means different entities in different contexts, while always being a String. This cost me a couple of hours of painful debugging to realize that:

  • makeKeyedValueCache(cacheKey: String?)
  • func getCachedData(cacheKey: String, ...) and func saveCachedData(_ storedItems: StoredItems, cacheKey: String, ...)

use cacheKey that look different, and are generated differently. The first one usually looks like com.launchdarkly.client...., and the second one is context.fullyQualifiedHashedKey()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair. It may be something we add ourselves then after this lands.

@tanderson-ld that is something you and I can decide on then if we want this now, later, or not at all.

let cacheKey: String
if let bundleId = Bundle.main.bundleIdentifier {
cacheKey = "\(Util.sha256base64(bundleId)).\(Util.sha256base64(self))"
} else {
cacheKey = Util.sha256base64(self)
}
return "com.launchdarkly.client.\(cacheKey)"
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this extension organizational or is there another reason for adding it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code reuse


final class FeatureFlagCache: FeatureFlagCaching {
let keyedValueCache: KeyedValueCaching
let maxCachedContexts: Int

init(serviceFactory: ClientServiceCreating, mobileKey: MobileKey, maxCachedContexts: Int) {
let cacheKey: String
if let bundleId = Bundle.main.bundleIdentifier {
cacheKey = "\(Util.sha256base64(bundleId)).\(Util.sha256base64(mobileKey))"
} else {
cacheKey = Util.sha256base64(mobileKey)
}
self.keyedValueCache = serviceFactory.makeKeyedValueCache(cacheKey: "com.launchdarkly.client.\(cacheKey)")
self.keyedValueCache = serviceFactory.makeKeyedValueCache(cacheKey: mobileKey.cacheKey())
self.maxCachedContexts = maxCachedContexts
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,14 @@
import Foundation

import OSLog
// sourcery: autoMockable
protocol KeyedValueCaching {
public protocol KeyedValueCaching {
func set(_ value: Data, forKey: String)
func data(forKey: String) -> Data?
func dictionary(forKey: String) -> [String: Any]?
func removeObject(forKey: String)
func removeAll()
func keys() -> [String]
}

extension UserDefaults: KeyedValueCaching {
func set(_ value: Data, forKey: String) {
set(value as Any?, forKey: forKey)
}

func removeAll() {
dictionaryRepresentation().keys.forEach { removeObject(forKey: $0) }
}

func keys() -> [String] {
dictionaryRepresentation().keys.map { String($0) }
}
public extension LDConfig {
typealias CacheFactory = (_ cacheKey: String?, _ logger: OSLog) -> KeyedValueCaching
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason to make the cache key optional? It forces a decision on what a default key would be in your cache implementations which I would prefer to avoid.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was dictated by the convertCacheData function, which is migrating UserDefaults with key = nil

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's actually in line with my later comment about this conversion being something associated with a specific cache implementation. Our existing implementation could take its cache key, but when doing the conversion bit, it can ignore that key then since it knows it didn't use to use it.

Also, at this point, I think we could argue that our cache converter could probably be cleaned up some. I don't think we have to support a v5 -> v9 migration path. So we could probably remove those steps entirely. What do you think @tanderson-ld

Copy link
Contributor

@tanderson-ld tanderson-ld Oct 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. Dropping support for converting v5 to remove the optionality of the cacheKey is a good tradeoff.

I think these are the pertinent lines.

        // Remove V5 cache data
        let standardDefaults = serviceFactory.makeKeyedValueCache(cacheKey: nil)
        standardDefaults.removeObject(forKey: "com.launchdarkly.dataManager.userEnvironments")

}
Loading