diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index 9ee9164e..56e8a023 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -15,6 +15,10 @@ 29FE1299280413D4008CC918 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29FE1297280413D4008CC918 /* Util.swift */; }; 29FE129A280413D4008CC918 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29FE1297280413D4008CC918 /* Util.swift */; }; 29FE129B280413D4008CC918 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29FE1297280413D4008CC918 /* Util.swift */; }; + 9AE1D0D12F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; + 9AE1D0D22F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; + 9AE1D0D32F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; + 9AE1D0D42F4A1B000001FD9C /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */; }; 3D2406142E0D90E000F91253 /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2406102E0D90E000F91253 /* Plugin.swift */; }; 3D2406152E0D90E000F91253 /* PluginMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D2406112E0D90E000F91253 /* PluginMetadata.swift */; }; 3D2406162E0D90E000F91253 /* EnvironmentMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D24060F2E0D90E000F91253 /* EnvironmentMetadata.swift */; }; @@ -214,6 +218,11 @@ 9A47F74D2F3D4CCF0001FD9C /* ContextSummarizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A47F74B2F3D4CCF0001FD9C /* ContextSummarizer.swift */; }; 9A47F74E2F3D4CCF0001FD9C /* ContextSummarizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A47F74B2F3D4CCF0001FD9C /* ContextSummarizer.swift */; }; 9A47F74F2F3D4CCF0001FD9C /* ContextSummarizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A47F74B2F3D4CCF0001FD9C /* ContextSummarizer.swift */; }; + 9AE1D0A12F4A1B000001FD9C /* EvaluationExposureDeduper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0A02F4A1B000001FD9C /* EvaluationExposureDeduper.swift */; }; + 9AE1D0A22F4A1B000001FD9C /* EvaluationExposureDeduper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0A02F4A1B000001FD9C /* EvaluationExposureDeduper.swift */; }; + 9AE1D0A32F4A1B000001FD9C /* EvaluationExposureDeduper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0A02F4A1B000001FD9C /* EvaluationExposureDeduper.swift */; }; + 9AE1D0A42F4A1B000001FD9C /* EvaluationExposureDeduper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0A02F4A1B000001FD9C /* EvaluationExposureDeduper.swift */; }; + 9AE1D0A62F4A1B000001FD9C /* EvaluationExposureDeduperSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0A52F4A1B000001FD9C /* EvaluationExposureDeduperSpec.swift */; }; A3047D642A606B6000F568E0 /* SDKEnvironmentReporterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3047D5D2A606B6000F568E0 /* SDKEnvironmentReporterSpec.swift */; }; A3047D652A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3047D5E2A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift */; }; A3047D662A606B6000F568E0 /* EnvironmentReporterChainBaseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3047D5F2A606B6000F568E0 /* EnvironmentReporterChainBaseSpec.swift */; }; @@ -317,6 +326,14 @@ A3A8BCD42B7EAA89009A77E4 /* SheddingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */; }; A3A8BCD52B7EAA89009A77E4 /* SheddingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */; }; A3BA7CE92BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; + 9AE1D0C12F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; + 9AE1D0C22F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; + 9AE1D0C32F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; + 9AE1D0C42F4A1B000001FD9C /* DedupingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */; }; + 9AE1D0B12F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; + 9AE1D0B22F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; + 9AE1D0B32F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; + 9AE1D0B42F4A1B000001FD9C /* HookDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */; }; A3BA7CEA2BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; A3BA7CEB2BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; A3BA7CEC2BD056920000DB28 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7CE82BD056920000DB28 /* Hook.swift */; }; @@ -451,6 +468,7 @@ /* Begin PBXFileReference section */ 29F9D19D2812E005008D12C0 /* ObjcLDValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjcLDValue.swift; sourceTree = ""; }; 29FE1297280413D4008CC918 /* Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Util.swift; sourceTree = ""; }; + 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnfairLock.swift; sourceTree = ""; }; 3D24060F2E0D90E000F91253 /* EnvironmentMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentMetadata.swift; sourceTree = ""; }; 3D2406102E0D90E000F91253 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = ""; }; 3D2406112E0D90E000F91253 /* PluginMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginMetadata.swift; sourceTree = ""; }; @@ -533,6 +551,8 @@ 83FEF8DE1F2667E4001CF12C /* EventReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventReporter.swift; sourceTree = ""; }; 9A47F7492F3D4CBF0001FD9C /* ContextSummarizerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextSummarizerSpec.swift; sourceTree = ""; }; 9A47F74B2F3D4CCF0001FD9C /* ContextSummarizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextSummarizer.swift; sourceTree = ""; }; + 9AE1D0A02F4A1B000001FD9C /* EvaluationExposureDeduper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationExposureDeduper.swift; sourceTree = ""; }; + 9AE1D0A52F4A1B000001FD9C /* EvaluationExposureDeduperSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationExposureDeduperSpec.swift; sourceTree = ""; }; A3047D5D2A606B6000F568E0 /* SDKEnvironmentReporterSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKEnvironmentReporterSpec.swift; sourceTree = ""; }; A3047D5E2A606B6000F568E0 /* IOSEnvironmentReporterSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IOSEnvironmentReporterSpec.swift; sourceTree = ""; }; A3047D5F2A606B6000F568E0 /* EnvironmentReporterChainBaseSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentReporterChainBaseSpec.swift; sourceTree = ""; }; @@ -570,6 +590,8 @@ A380B0982B60178D00AB64A6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; A3A8BCD12B7EAA89009A77E4 /* SheddingQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SheddingQueue.swift; sourceTree = ""; }; A3BA7CE82BD056920000DB28 /* Hook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hook.swift; sourceTree = ""; }; + 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DedupingHook.swift; sourceTree = ""; }; + 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookDecorator.swift; sourceTree = ""; }; A3BA7CED2BD059180000DB28 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; A3BA7CF22BD05A280000DB28 /* EvaluationSeriesContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationSeriesContext.swift; sourceTree = ""; }; A3BA7D032BD2BD620000DB28 /* TestContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContext.swift; sourceTree = ""; }; @@ -672,6 +694,7 @@ isa = PBXGroup; children = ( 9A47F7492F3D4CBF0001FD9C /* ContextSummarizerSpec.swift */, + 9AE1D0A52F4A1B000001FD9C /* EvaluationExposureDeduperSpec.swift */, 50EE85C62EA0749C007CC662 /* TimeoutExecutorSpec.swift */, A3047D5B2A606A0000F568E0 /* EnvironmentReporting */, B46F344025E6DB7D0078D45F /* DiagnosticReporterSpec.swift */, @@ -762,6 +785,7 @@ 8354EFDC1F26380700C05156 /* LDClient.swift */, B495A8A12787762C0051977C /* LDClientVariation.swift */, 29FE1297280413D4008CC918 /* Util.swift */, + 9AE1D0D02F4A1B000001FD9C /* UnfairLock.swift */, 8354EFE61F263E4200C05156 /* Models */, 83FEF8D91F2666BF001CF12C /* ServiceObjects */, 831D8B701F71D3A600ED65E8 /* Networking */, @@ -1015,6 +1039,9 @@ A350386C2F4F4F610032BA9F /* IdentifySeriesContext.swift */, FED0000000000000000000C0 /* TrackSeriesContext.swift */, A3BA7CE82BD056920000DB28 /* Hook.swift */, + 9AE1D0C02F4A1B000001FD9C /* DedupingHook.swift */, + 9AE1D0B02F4A1B000001FD9C /* HookDecorator.swift */, + 9AE1D0A02F4A1B000001FD9C /* EvaluationExposureDeduper.swift */, A3BA7CED2BD059180000DB28 /* Metadata.swift */, A3BA7CF22BD05A280000DB28 /* EvaluationSeriesContext.swift */, ); @@ -1391,6 +1418,7 @@ files = ( 83906A7B21190B7700D7D3C5 /* DateFormatter.swift in Sources */, 9A47F74F2F3D4CCF0001FD9C /* ContextSummarizer.swift in Sources */, + 9AE1D0A42F4A1B000001FD9C /* EvaluationExposureDeduper.swift in Sources */, 831188502113ADEF00D77CB5 /* EnvironmentReporter.swift in Sources */, 831188682113AE5600D77CB5 /* ObjcLDClient.swift in Sources */, 831188572113AE0B00D77CB5 /* FlagChangeNotifier.swift in Sources */, @@ -1430,6 +1458,7 @@ C443A40D2315AA4D00145710 /* NetworkReporter.swift in Sources */, A358D6EF2A4DE9A600270C60 /* TVOSEnvironmentReporter.swift in Sources */, 29FE129B280413D4008CC918 /* Util.swift in Sources */, + 9AE1D0D12F4A1B000001FD9C /* UnfairLock.swift in Sources */, 831188652113AE4600D77CB5 /* Date.swift in Sources */, 831188672113AE4D00D77CB5 /* Thread.swift in Sources */, C443A40823145FEE00145710 /* ConnectionInformationStore.swift in Sources */, @@ -1451,6 +1480,8 @@ 8311884E2113ADE500D77CB5 /* Event.swift in Sources */, A36EDFCB2853883400D91B05 /* ObjcLDReference.swift in Sources */, A3BA7CEC2BD056920000DB28 /* Hook.swift in Sources */, + 9AE1D0C12F4A1B000001FD9C /* DedupingHook.swift in Sources */, + 9AE1D0B12F4A1B000001FD9C /* HookDecorator.swift in Sources */, 832D68A5224A38FC005F052A /* CacheConverter.swift in Sources */, A35AD4632A619E45005A8DCB /* SystemCapabilities.swift in Sources */, A358D6FA2A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, @@ -1474,6 +1505,7 @@ B468E71224B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */, A36EDFCF2853C50B00D91B05 /* ObjcLDContext.swift in Sources */, 9A47F74C2F3D4CCF0001FD9C /* ContextSummarizer.swift in Sources */, + 9AE1D0A12F4A1B000001FD9C /* EvaluationExposureDeduper.swift in Sources */, 831EF34320655E730001C643 /* LDCommon.swift in Sources */, A3C6F7662B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */, 831EF34420655E730001C643 /* LDConfig.swift in Sources */, @@ -1525,6 +1557,7 @@ 831EF35C20655E730001C643 /* HTTPURLResponse.swift in Sources */, C443A40723145FEE00145710 /* ConnectionInformationStore.swift in Sources */, 29FE129A280413D4008CC918 /* Util.swift in Sources */, + 9AE1D0D22F4A1B000001FD9C /* UnfairLock.swift in Sources */, 831EF35D20655E730001C643 /* HTTPURLRequest.swift in Sources */, 3D3AB9452A4F16FE003AECF1 /* ReportingConsts.swift in Sources */, 835E4C54206BDF8D004C6E6C /* EnvironmentReporter.swift in Sources */, @@ -1536,6 +1569,8 @@ 8347BB0E21F147E100E56BCD /* LDTimer.swift in Sources */, B495A8A42787762C0051977C /* LDClientVariation.swift in Sources */, A3BA7CEB2BD056920000DB28 /* Hook.swift in Sources */, + 9AE1D0C22F4A1B000001FD9C /* DedupingHook.swift in Sources */, + 9AE1D0B22F4A1B000001FD9C /* HookDecorator.swift in Sources */, A3599E8A2A4B4AD400DB5C67 /* Modifier.swift in Sources */, 831EF36320655E730001C643 /* Date.swift in Sources */, 831EF36520655E730001C643 /* Thread.swift in Sources */, @@ -1556,6 +1591,7 @@ files = ( 831D8B6F1F71532300ED65E8 /* HTTPHeaders.swift in Sources */, 9A47F74D2F3D4CCF0001FD9C /* ContextSummarizer.swift in Sources */, + 9AE1D0A22F4A1B000001FD9C /* EvaluationExposureDeduper.swift in Sources */, 835E1D3F1F63450A00184DB4 /* ObjcLDClient.swift in Sources */, 83EBCBB320DABE1B003A7142 /* FlagRequestTracker.swift in Sources */, 837EF3742059C237009D628A /* Log.swift in Sources */, @@ -1594,6 +1630,7 @@ A3A8BCD22B7EAA89009A77E4 /* SheddingQueue.swift in Sources */, 8354EFE11F26380700C05156 /* LDConfig.swift in Sources */, 29FE1298280413D4008CC918 /* Util.swift in Sources */, + 9AE1D0D32F4A1B000001FD9C /* UnfairLock.swift in Sources */, C443A40F23186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */, C443A40A2315AA4D00145710 /* NetworkReporter.swift in Sources */, 831D8B741F72994600ED65E8 /* FlagStore.swift in Sources */, @@ -1616,6 +1653,8 @@ A36EDFC82853883400D91B05 /* ObjcLDReference.swift in Sources */, 8358F2621F47747F00ECE1AF /* FlagChangeObserver.swift in Sources */, A3BA7CE92BD056920000DB28 /* Hook.swift in Sources */, + 9AE1D0C32F4A1B000001FD9C /* DedupingHook.swift in Sources */, + 9AE1D0B32F4A1B000001FD9C /* HookDecorator.swift in Sources */, 832D68A2224A38FC005F052A /* CacheConverter.swift in Sources */, A35AD4602A619E45005A8DCB /* SystemCapabilities.swift in Sources */, A358D6F72A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, @@ -1681,6 +1720,7 @@ 831425AF206ABB5300F2EF36 /* EnvironmentReportingMock.swift in Sources */, 838AB53F1F72A7D5006F03F5 /* FlagSynchronizerSpec.swift in Sources */, 9A47F74A2F3D4CBF0001FD9C /* ContextSummarizerSpec.swift in Sources */, + 9AE1D0A62F4A1B000001FD9C /* EvaluationExposureDeduperSpec.swift in Sources */, A3FFE1132B7D4BA2009EF93F /* LDValueDecoderSpec.swift in Sources */, 9AF0E1012F5A1C000001FD9C /* LDValueSpec.swift in Sources */, A3570F5A28527B8200CF241A /* LDContextCodableSpec.swift in Sources */, @@ -1699,6 +1739,7 @@ files = ( 83D9EC752062DEAB004D7FA6 /* LDCommon.swift in Sources */, 9A47F74E2F3D4CCF0001FD9C /* ContextSummarizer.swift in Sources */, + 9AE1D0A32F4A1B000001FD9C /* EvaluationExposureDeduper.swift in Sources */, 83D9EC762062DEAB004D7FA6 /* LDConfig.swift in Sources */, 83EBCBB420DABE1B003A7142 /* FlagRequestTracker.swift in Sources */, 83D9EC772062DEAB004D7FA6 /* LDClient.swift in Sources */, @@ -1737,6 +1778,7 @@ A3A8BCD32B7EAA89009A77E4 /* SheddingQueue.swift in Sources */, C443A41023186A4F00145710 /* ConnectionModeChangeObserver.swift in Sources */, 29FE1299280413D4008CC918 /* Util.swift in Sources */, + 9AE1D0D42F4A1B000001FD9C /* UnfairLock.swift in Sources */, 83D9EC882062DEAB004D7FA6 /* FlagChangeNotifier.swift in Sources */, C443A40B2315AA4D00145710 /* NetworkReporter.swift in Sources */, 83D9EC892062DEAB004D7FA6 /* EventReporter.swift in Sources */, @@ -1759,6 +1801,8 @@ B468E71124B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */, A36EDFC92853883400D91B05 /* ObjcLDReference.swift in Sources */, A3BA7CEA2BD056920000DB28 /* Hook.swift in Sources */, + 9AE1D0C42F4A1B000001FD9C /* DedupingHook.swift in Sources */, + 9AE1D0B42F4A1B000001FD9C /* HookDecorator.swift in Sources */, 83D9EC952062DEAB004D7FA6 /* Date.swift in Sources */, A35AD4612A619E45005A8DCB /* SystemCapabilities.swift in Sources */, A358D6F82A4DF1D500270C60 /* SDKEnvironmentReporter.swift in Sources */, diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 137faa54..8a9e15c7 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -276,8 +276,15 @@ public class LDClient { } let config: LDConfig + /// Identifies this client's environment to a hook, without handing it the mobile key that identifies the + /// environment to LaunchDarkly. Hashed once here rather than per evaluation, because a deduping hook asks for it on + /// a path an application may take on every redraw of a view. + let mobileKeyHash: String let service: DarklyServiceProvider - var hooks: [Hook] + /// The hooks registered with this client: the configuration's. + /// Constant, so that a series reading it more than once, as an evaluation series does for its before and after + /// stages, runs the same hooks in both, whichever thread the evaluation was made from. + let hooks: [Hook] private(set) var context: LDContext /** @@ -949,26 +956,33 @@ public class LDClient { private var initializedQueue = DispatchQueue(label: "com.launchdarkly.LDClient.initializedQueue") private var identifyQueue = SheddingQueue() - private init(serviceFactory: ClientServiceCreating, configuration: LDConfig, startContext: LDContext?, completion: (() -> Void)? = nil) { - self.serviceFactory = serviceFactory - self.hooks = Array(configuration.hooks) - environmentReporter = self.serviceFactory.makeEnvironmentReporter(config: configuration) - - // Collect plugin hooks before calling beforeIdentify, so plugin hooks participate in the init identify lifecycle. - let initSdkMetadata = SdkMetadata(name: SystemCapabilities.systemName, version: ReportingConsts.sdkVersion) - let initEnvironmentMetadata = EnvironmentMetadata( + /// The hooks the configuration registers, followed by the hooks the plugins contribute. Collected before the init + /// identify series opens, so that plugin hooks take part in it. + private static func collectHooks(configuration: LDConfig, environmentReporter: EnvironmentReporting) -> [Hook] { + var hooks = Array(configuration.hooks) + let metadata = EnvironmentMetadata( applicationInfo: environmentReporter.applicationInfo, - sdkMetadata: initSdkMetadata, + sdkMetadata: SdkMetadata(name: SystemCapabilities.systemName, version: ReportingConsts.sdkVersion), credential: configuration.mobileKey ) for plugin in configuration.plugins { do { - let pluginHooks = try plugin.getHooks(metadata: initEnvironmentMetadata) - self.hooks.append(contentsOf: pluginHooks) + hooks.append(contentsOf: try plugin.getHooks(metadata: metadata)) } catch { os_log("Exception thrown getting hooks for plugin %@. Unable to get hooks, plugin will not be registered.", log: configuration.logger, type: .error, plugin.getMetadata().getName()) } } + return hooks + } + + private init(serviceFactory: ClientServiceCreating, configuration: LDConfig, startContext: LDContext?, completion: (() -> Void)? = nil) { + // Set before the hooks below run, so that the environment a hook is told about is this client's rather than the + // primary one's. + self.mobileKeyHash = Util.sha256base64(configuration.mobileKey) + self.serviceFactory = serviceFactory + environmentReporter = self.serviceFactory.makeEnvironmentReporter(config: configuration) + let hooks = LDClient.collectHooks(configuration: configuration, environmentReporter: environmentReporter) + self.hooks = hooks flagCache = self.serviceFactory.makeFeatureFlagCache(mobileKey: configuration.mobileKey, maxCachedContexts: configuration.maxCachedContexts) flagStore = self.serviceFactory.makeFlagStore() @@ -976,8 +990,7 @@ public class LDClient { throttler = self.serviceFactory.makeThrottler(environmentReporter: environmentReporter) config = configuration - let anonymousContext = LDContext() - context = startContext ?? anonymousContext + context = startContext ?? LDContext() if config.autoEnvAttributes { context = AutoEnvContextModifier(environmentReporter: environmentReporter, logger: config.logger).modifyContext(context) diff --git a/LaunchDarkly/LaunchDarkly/LDClientVariation.swift b/LaunchDarkly/LaunchDarkly/LDClientVariation.swift index d390e656..a91af227 100644 --- a/LaunchDarkly/LaunchDarkly/LDClientVariation.swift +++ b/LaunchDarkly/LaunchDarkly/LDClientVariation.swift @@ -143,27 +143,27 @@ extension LDClient { return variationDetailInternal(flagKey, defaultValue, needsReason: true, methodName: "variationDetail") } - private func evaluateWithHooks(flagKey: LDFlagKey, defaultValue: D, methodName: String, evaluation: () -> LDEvaluationDetail) -> LDEvaluationDetail where D: LDValueConvertible, D: Decodable { - guard !self.hooks.isEmpty else { + private func evaluateWithHooks(flagKey: LDFlagKey, defaultValue: D, methodName: String, featureFlag: FeatureFlag?, evaluation: () -> LDEvaluationDetail) -> LDEvaluationDetail where D: LDValueConvertible, D: Decodable { + guard !hooks.isEmpty else { return evaluation() } - let seriesContext = EvaluationSeriesContext(flagKey: flagKey, context: self.context, defaultValue: defaultValue.toLDValue(), methodName: methodName) - let hookData = self.execute_before_evaluation(seriesContext: seriesContext) + let seriesContext = EvaluationSeriesContext(flagKey: flagKey, context: self.context, defaultValue: defaultValue.toLDValue(), methodName: methodName, mobileKeyHash: mobileKeyHash, featureFlag: featureFlag) + let hookData = self.execute_before_evaluation(hooks: hooks, seriesContext: seriesContext) let evaluationResult = evaluation() - _ = self.execute_after_evaluation(seriesContext: seriesContext, hookData: hookData, evaluationDetail: evaluationResult.map { value in return value.toLDValue()}) + _ = self.execute_after_evaluation(hooks: hooks, seriesContext: seriesContext, hookData: hookData, evaluationDetail: evaluationResult.map { value in return value.toLDValue()}) return evaluationResult } - private func execute_before_evaluation(seriesContext: EvaluationSeriesContext) -> [EvaluationSeriesData] { - return self.hooks.map { hook in + private func execute_before_evaluation(hooks: [Hook], seriesContext: EvaluationSeriesContext) -> [EvaluationSeriesData] { + return hooks.map { hook in hook.beforeEvaluation(seriesContext: seriesContext, seriesData: EvaluationSeriesData()) } } - private func execute_after_evaluation(seriesContext: EvaluationSeriesContext, hookData: [EvaluationSeriesData], evaluationDetail: LDEvaluationDetail) -> [EvaluationSeriesData] { - return zip(self.hooks, hookData).reversed().map { (hook, data) in + private func execute_after_evaluation(hooks: [Hook], seriesContext: EvaluationSeriesContext, hookData: [EvaluationSeriesData], evaluationDetail: LDEvaluationDetail) -> [EvaluationSeriesData] { + return zip(hooks, hookData).reversed().map { (hook, data) in return hook.afterEvaluation(seriesContext: seriesContext, seriesData: data, evaluationDetail: evaluationDetail) } } @@ -174,9 +174,11 @@ extension LDClient { } private func variationDetailInternal(_ flagKey: LDFlagKey, _ defaultValue: T, needsReason: Bool, methodName: String, visited: inout Set?) -> LDEvaluationDetail where T: Decodable, T: LDValueConvertible { - return evaluateWithHooks(flagKey: flagKey, defaultValue: defaultValue, methodName: methodName) { + // Read once, so that the flag a hook is told the evaluation is about to return is the flag it does return: were + // the store read again below, an update landing in between would leave the two describing different results. + let featureFlag = flagStore.featureFlag(for: flagKey) + return evaluateWithHooks(flagKey: flagKey, defaultValue: defaultValue, methodName: methodName, featureFlag: featureFlag) { var result: LDEvaluationDetail - let featureFlag = flagStore.featureFlag(for: flagKey) if let featureFlag = featureFlag { if let prerequisites = featureFlag.prerequisites, !prerequisites.isEmpty { // Recurse on prerequisites to emulate prereq evaluations occurring with desirable side effects diff --git a/LaunchDarkly/LaunchDarkly/Models/Hooks/DedupingHook.swift b/LaunchDarkly/LaunchDarkly/Models/Hooks/DedupingHook.swift new file mode 100644 index 00000000..6b2c265d --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/Models/Hooks/DedupingHook.swift @@ -0,0 +1,100 @@ +import Foundation + +/** + Wraps a hook so that repeated evaluations resolving to the same result do not reach it again within a time window. + + The wrapped hook is told about a flag when its result changes, and at most once per window while the result stays the + same. This is useful for reducing the telemetry volume produced by frequent re-evaluations, for example a flag that is + read on every redraw of a view. Deduplication is opt-in: a hook that is registered unwrapped observes every evaluation. + + This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. It is + experimental. + + ```swift + config.hooks = [ + MetricsHook(), // observes every evaluation + DedupingHook(ObservabilityHook()), // default window + DedupingHook(TelemetryHook(), window: 60), + DedupingHook(ExperimentHook(), deduper: sharedDeduper) + ] + ``` + + Two evaluations resolve to the same result when they agree on everything `EvaluationExposureKey` describes. + + An evaluation the SDK has no flag data for resolves to the default value, and is the same result as another that does. + Evaluations made before the client has flags are of that kind, so the wrapped hook is told about one of them and then + told about the flag again as soon as its data arrives. + + A suppressed evaluation reaches neither `beforeEvaluation` nor `afterEvaluation`, because hooks pair their stages. The + identify and track stages are always forwarded. Analytics events are unaffected: feature, debug, and summary events are + still recorded for every evaluation, so the evaluation counts LaunchDarkly reports for your flags do not change. + + What the wrapped hook has been told about is cleared by `LDClient.identify(context:)`, so the first evaluation of each + flag after an identify always reaches it. + + Give each hook its own instance unless you intend hooks to share a window: the first hook to be told about an evaluation + starts the window that suppresses the rest. + + Wrap outermost when you stack hooks that wrap other hooks. Suppressing an evaluation means returning series data that + says so in place of what the stage was given, so a wrapper outside this one does not get back what it stored in its own + before stage. A wrapper inside this one is unaffected, since a suppressed evaluation never reaches it. + */ +public final class DedupingHook: HookDecorator { + // Namespaced because it travels in series data that the wrapped hook may also write to. + private static let suppressedKey = "com.launchdarkly.DedupingHook.suppressed" + + private let deduper: EvaluationExposureDeduper + + /** + - parameter delegate: The hook to wrap. + - parameter window: The dedupe window, in seconds. Defaults to `EvaluationExposureDeduper.defaultWindow`. A value of + zero or less forwards every evaluation. + */ + public convenience init(_ delegate: Hook, window: TimeInterval = EvaluationExposureDeduper.defaultWindow) { + self.init(delegate, deduper: EvaluationExposureDeduper(window: window)) + } + + /** + - parameter delegate: The hook to wrap. + - parameter deduper: Decides which evaluations reach the wrapped hook. + */ + public init(_ delegate: Hook, deduper: EvaluationExposureDeduper) { + self.deduper = deduper + super.init(delegate) + } + + /** + Forwards the evaluation unless the wrapped hook has just been told about the same result. + + The decision is made here, before the evaluation runs, so that a suppressed evaluation reaches neither stage of the + wrapped hook. An evaluation whose result the SDK did not describe, which is to say a series context built by + something other than the SDK, is always forwarded. + */ + public override func beforeEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData) -> EvaluationSeriesData { + if let key = seriesContext.evaluationExposureKey, !deduper.shouldRecord(key: key) { + // Recognized by identity below, so that stacked instances each recognize only their own suppressions. + return [DedupingHook.suppressedKey: self] + } + return super.beforeEvaluation(seriesContext: seriesContext, seriesData: seriesData) + } + + /// Forwards the result unless this instance suppressed the series in its before stage. + public override func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail) -> EvaluationSeriesData { + if let marker = seriesData[DedupingHook.suppressedKey], marker as AnyObject === self { + return seriesData + } + return super.afterEvaluation(seriesContext: seriesContext, seriesData: seriesData, evaluationDetail: evaluationDetail) + } + + /** + Forgets which results the wrapped hook has been told about, then forwards the stage. + + Evaluations observed before an identify describe an earlier point in the application's lifecycle, so they are + reported again afterwards. This happens even when the context is unchanged, so that identify is a reliable way for an + application to mark a new phase of a session. + */ + public override func beforeIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData) -> IdentifySeriesData { + deduper.reset() + return super.beforeIdentify(seriesContext: seriesContext, seriesData: seriesData) + } +} diff --git a/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationExposureDeduper.swift b/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationExposureDeduper.swift new file mode 100644 index 00000000..eeb15b05 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationExposureDeduper.swift @@ -0,0 +1,217 @@ +import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + +/** + Identifies the evaluation result a hook is about to be told about, so that an `EvaluationExposureDeduper` can recognize + a repeat of it. + + This struct is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. It is + experimental. Which components make up the identity of an evaluation is the most likely part to be revised, so a + deduper subclass that reasons about them may need to change with it. + + Two evaluations are the same exposure when every component here matches. The value is included directly rather than + inferred from the variation and version: those are the identity LaunchDarkly uses to bucket summary events, but neither + by itself guarantees that the payload is unchanged. The environment is a component because a hook set on `LDConfig` is + one instance shared by the clients for every environment in `secondaryMobileKeys`, and so is its deduper. It is + identified by a hash of the mobile key rather than by the key itself, so that a hook cannot read the credential out of + what it is told, and rather than by the configured environment name, which is arbitrary metadata that is always + `"default"` for the primary environment. + + The components describe the result the evaluation returns, which is how the SDK identifies an evaluation on analytics + events too. An evaluation the SDK has no flag data for returns the default value, and so is described by that value with + no variation and no version, the same identity it summarizes such an evaluation under. Evaluations made before the + client has flags are of that kind, as are evaluations of a flag that does not exist, so the data arriving changes the + value, variation, and version, and the hook is told about the flag again rather than waiting out a window. A flag whose + data carries no value, which is what a flag that is off without an off variation has, likewise returns the default value + and is described by it, under the flag's own variation and version. The environment is never unknown this way: it is + derived from the configuration, so it is fixed before the client it belongs to evaluates anything. + + The reason the SDK gives for a result is not a component, so neither is the experiment membership drawn from it. A + prerequisite that starts failing to the variation an experiment had been choosing leaves the value, the variation, and + the version unchanged while moving the flag out of that experiment, and the evaluations that follow are repeats here. + The analytics the SDK sends are untouched by any of this, each carrying its own reason, so what LaunchDarkly attributes + to an experiment does not depend on the window; a hook that reads the reason itself is what can miss such a change + until the window elapses. + */ +public struct EvaluationExposureKey: Hashable { + /// Identifies the environment the evaluation was made against, for comparison only: the mobile key is hashed so + /// that a hook is not handed the credential, and the hash the SDK uses is not part of its contract. All that is + /// guaranteed is that two evaluations made against the same environment give the same value, and evaluations made + /// against different environments do not. + public let mobileKeyHash: String + /// The key of the flag that was evaluated. + public let flagKey: LDFlagKey + /// The value the evaluation returns, which is the default value if the flag was not found. + public let value: LDValue + /// The index of the variation the result came from, or `nil` if the evaluation did not resolve to one. + public let variation: Int? + /// The flag version reported on events, or `nil` if the flag was not found. + public let flagVersion: Int? + /// The fully qualified key of the evaluation context. + public let fullyQualifiedContextKey: String + + /** + - parameter mobileKeyHash: A hash of the mobile key of the environment the evaluation was made against. + - parameter flagKey: The key of the flag that was evaluated. + - parameter value: The value the evaluation returns, which is the default value if the flag was not found. + Defaults to null; prefer stating it, since the variation and version do not by themselves distinguish one result + from another. + - parameter variation: The index of the variation the result came from. + - parameter flagVersion: The flag version reported on events. + - parameter fullyQualifiedContextKey: The fully qualified key of the evaluation context. + */ + public init(mobileKeyHash: String, + flagKey: LDFlagKey, + variation: Int?, + flagVersion: Int?, + fullyQualifiedContextKey: String, + value: LDValue = .null) { + self.mobileKeyHash = mobileKeyHash + self.flagKey = flagKey + self.value = value + self.variation = variation + self.flagVersion = flagVersion + self.fullyQualifiedContextKey = fullyQualifiedContextKey + } +} + +/** + Decides whether a hook should be told about an evaluation, so that repeated evaluations resolving to the same result do + not invoke the hook again within a time window. + + This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. It is + experimental. Subclassing it to change which evaluations are deduplicated is supported, but the shape it is subclassed + through, and the components of `EvaluationExposureKey` a subclass reasons about, may change. + + Deduplication is opt-in per hook: a hook is told about every evaluation until you wrap it in a `DedupingHook`, which is + what consults a deduper. + + ```swift + config.hooks = [ + MetricsHook(), // told about every evaluation + DedupingHook(ObservabilityHook()), // default window + DedupingHook(TelemetryHook(), window: 30), + DedupingHook(ExperimentHook(), deduper: sharedDeduper) + ] + ``` + + This class is the SDK's implementation: it remembers the result each flag last reported, and tells the hook about the + flag again as soon as that result changes, or once the window elapses while it stays the same. Tracking one result per + flag rather than every result seen keeps a flag that flips back and forth from hiding the flips, and holds one record per + flag the application evaluates, so the window is the only thing there is to configure. + + A deduper is consulted once per evaluation, before the series opens, so a suppressed evaluation invokes neither + `beforeEvaluation` nor `afterEvaluation`. Implementations must be thread-safe, because evaluations may be made from any + thread. Give each hook its own instance unless you intend hooks to share a window: the first hook to be told about an + exposure starts the window that suppresses the rest. + */ +open class EvaluationExposureDeduper { + /// The dedupe window used by a deduper built without a window of its own. (10 minutes) + public static let defaultWindow: TimeInterval = 600 + + /** + Reads the clock a window is measured against, in seconds. This is what `shouldRecord(key:now:)` reads when it is + not given a time. + + `CLOCK_MONOTONIC` counts from an arbitrary point rather than from the epoch, so that correcting the device clock + cannot stretch a window: were this `Date()`, a correction that moved the clock backwards would leave every recorded + time in the future and suppress those flags until real time caught up. It is POSIX rather than one of Darwin's own + clocks, so the same reading is available on every platform Swift builds for. + + On Apple platforms it keeps advancing while the device sleeps, unlike `mach_absolute_time` and everything built on + it, such as `DispatchTime.now()` and `ProcessInfo.systemUptime`, so a window is an interval of real time rather + than of awake time. Where a platform's monotonic clock instead stops while the host is suspended, a window outlasts + the suspension, which holds a repeat back for longer rather than reporting one too often. + */ + public static func monotonicNow() -> TimeInterval { + var now = timespec() + clock_gettime(CLOCK_MONOTONIC, &now) + return TimeInterval(now.tv_sec) + TimeInterval(now.tv_nsec) / nanosecondsPerSecond + } + + private static let nanosecondsPerSecond: TimeInterval = 1_000_000_000 + + private let window: TimeInterval + + // A plain lock rather than a DispatchQueue, because this is consulted once per evaluation: + // `DispatchQueue.sync` measured around 3.4µs per call once a second thread reaches it, against under 200ns here. + // Reading the records concurrently was measured too and lost to a plain lock, the section being one dictionary + // lookup and one comparison, so there is less to overlap than dispatching costs. + private let lock = UnfairLock() + // Last result reported for each flag, per environment. Entries stay until `reset()`. + // Should only be used while holding the lock. + private var lastReported: [TrackedFlag: LastReported] = [:] + + /** + - parameter window: The dedupe window, in seconds. Defaults to `defaultWindow`. A value of zero or less disables + deduplication, so every evaluation reaches the hook. + */ + public init(window: TimeInterval = EvaluationExposureDeduper.defaultWindow) { + self.window = window + } + + /** + Returns whether the hook should be told about the evaluation identified by the given key, and if so starts a new + dedupe window for the flag. + + `DedupingHook` calls this once per evaluation. This implementation answers true when the flag is reporting a + different result than it last did, and when the window has elapsed on the result it is repeating. See + `EvaluationExposureKey` for what makes two evaluations the same result. + + The check and the update are performed together so that concurrent evaluations of the same flag cannot both be told + to record. + + - parameter key: The key identifying the evaluation result. + - parameter now: A reading of a clock that counts from an arbitrary point, in seconds. Defaults to + `monotonicNow()`, which is not a time of day; see it for why a window is not measured against `Date()`. + */ + open func shouldRecord(key: EvaluationExposureKey, now: TimeInterval = EvaluationExposureDeduper.monotonicNow()) -> Bool { + guard window > 0 + else { return true } + + lock.lock() + defer { lock.unlock() } + + let flag = TrackedFlag(mobileKeyHash: key.mobileKeyHash, flagKey: key.flagKey) + if let reported = lastReported[flag], reported.reportedAt > now - window, reported.isSameResult(as: key) { + return false + } + + lastReported[flag] = LastReported(key: key, reportedAt: now) + return true + } + + /// Clears all recorded exposures, so the next evaluation of each is reported again. `DedupingHook` calls this when + /// the evaluation context changes. + open func reset() { + lock.lock() + defer { lock.unlock() } + + lastReported.removeAll() + } +} + +/// The flag a record belongs to. The environment is part of it because a hook set on `LDConfig` is one instance shared +/// by the clients for every environment in `secondaryMobileKeys`: were the environments to share a record, each would +/// look like the other having changed its result, and neither would ever be suppressed. +private struct TrackedFlag: Hashable { + let mobileKeyHash: String + let flagKey: LDFlagKey +} + +/// The result a flag last reported, and when. +private struct LastReported { + let key: EvaluationExposureKey + let reportedAt: TimeInterval + + /// Holding the key rather than a copy of the components that describe its result is what keeps this from having to + /// be revisited whenever `EvaluationExposureKey` gains one. The environment and flag key it also compares are equal + /// by the time this is asked, since a record is only ever found under the `TrackedFlag` they make up. + func isSameResult(as key: EvaluationExposureKey) -> Bool { + return self.key == key + } +} diff --git a/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift b/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift index 77fb4ddf..47c161f4 100644 --- a/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift +++ b/LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift @@ -11,10 +11,57 @@ public class EvaluationSeriesContext { /// A string identifing the name of the method called. public let methodName: String - init(flagKey: String, context: LDContext, defaultValue: LDValue, methodName: String) { + // The evaluation's own read of the flag, rather than the key describing it, so that an evaluation reaching only + // hooks that never ask what its result is builds nothing to describe it. A nil environment is what says this + // context was built by something other than the SDK, and so has no result to describe at all. + private let mobileKeyHash: String? + private let featureFlag: FeatureFlag? + + init(flagKey: String, context: LDContext, defaultValue: LDValue, methodName: String, + mobileKeyHash: String? = nil, featureFlag: FeatureFlag? = nil) { self.flagKey = flagKey self.context = context self.defaultValue = defaultValue self.methodName = methodName + self.mobileKeyHash = mobileKeyHash + self.featureFlag = featureFlag + } + + /** + The key identifying the result this evaluation will return, for a hook that decides what to do with an evaluation by + whether it has seen the same result before. `DedupingHook` is such a hook. + + This property is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. It + is experimental. + + This describes the flag rather than the evaluation result because a deduping hook decides before the series opens: + hooks pair their stages, so a hook that opens a span in `beforeEvaluation` and closes it in `afterEvaluation` would + be left holding an open span were only the after stage suppressed. It is the evaluation's own read of the flag, the + one its result is derived from, so it identifies the result either stage is about to see, and every hook that asks + is told about that same result. + + See `EvaluationExposureKey` for what makes two evaluations the same exposure. + + This is nil when the context was not built by the SDK, and so has no result to describe. + */ + public var evaluationExposureKey: EvaluationExposureKey? { + guard let mobileKeyHash = mobileKeyHash else { + return nil + } + + // The value the evaluation returns, which is the default value when there is no value to return: a flag the SDK + // has no data for, and a flag whose data carries no value, both fall back to it, as they do on the event the + // evaluation records. A value the calling method cannot decode into the type it was asked for also falls back + // to the default, but is not recognized here, because that type is not part of the series context. + let flagValue = featureFlag?.value ?? .null + + return EvaluationExposureKey( + mobileKeyHash: mobileKeyHash, + flagKey: flagKey, + variation: featureFlag?.variation, + flagVersion: featureFlag?.versionForEvents, + fullyQualifiedContextKey: context.fullyQualifiedKey(), + value: flagValue == .null ? defaultValue : flagValue + ) } } diff --git a/LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift b/LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift index 1c1f54f0..bbf93236 100644 --- a/LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift +++ b/LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift @@ -11,6 +11,11 @@ public typealias EvaluationSeriesData = [String: Any] public typealias IdentifySeriesData = [String: Any] /// Protocol for extending SDK functionality via hooks. +/// +/// Note that each stage below has a default implementation that does nothing, so a hook implements only the stages it +/// cares about. A `HookDecorator` wraps a hook and forwards every stage to it, which is what a hook that adds behavior +/// to another hook is built on. To deduplicate the repeated evaluations observed by one hook, wrap it in a +/// `DedupingHook` and register the wrapper. public protocol Hook { /// Get metadata about the hook implementation. func metadata() -> Metadata @@ -70,6 +75,7 @@ public protocol Hook { /// - Parameters: /// - seriesContext: Contains information about the track operation being performed. This is not mutable. func afterTrack(seriesContext: TrackSeriesContext) + } public extension Hook { @@ -110,4 +116,5 @@ public extension Hook { /// Default implementation is a no-op. func afterTrack(seriesContext: TrackSeriesContext) { } + } diff --git a/LaunchDarkly/LaunchDarkly/Models/Hooks/HookDecorator.swift b/LaunchDarkly/LaunchDarkly/Models/Hooks/HookDecorator.swift new file mode 100644 index 00000000..5daa5a77 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/Models/Hooks/HookDecorator.swift @@ -0,0 +1,96 @@ +import Foundation + +/** + A hook that wraps another hook, forwarding every stage to it. A subclass adds behavior to a hook without changing it, + and is registered in place of the hook it wraps. + + Every stage forwards to the wrapped hook, so a subclass overrides only the stages it changes, and calls `super` to + forward the ones it does. The stages it leaves alone still reach the wrapped hook. + + An override that never calls `super` stops forwarding that stage. For the identify and track stages that means a + decorator swallows something it has no reason to: a `DedupingHook` inside such an override is never told to forget what + it has reported, and goes on suppressing evaluations across an identify. + + ```swift + class FlagFilteringHook: HookDecorator { + private let flagKeys: Set + + init(_ delegate: Hook, flagKeys: Set) { + self.flagKeys = flagKeys + super.init(delegate) + } + + override func beforeEvaluation(seriesContext: EvaluationSeriesContext, + seriesData: EvaluationSeriesData) -> EvaluationSeriesData { + guard flagKeys.contains(seriesContext.flagKey) + else { return seriesData } + return super.beforeEvaluation(seriesContext: seriesContext, seriesData: seriesData) + } + } + ``` + + That hook filters evaluations and still forwards identify and track, which it never mentions. + + `DedupingHook` is the decorator the SDK ships: it forwards an evaluation series only when the flag's result is one its + hook has not just been told about. + + Decorators stack, so a hook may be wrapped in as many as it needs, each wrapping the one inside it: + + ```swift + config.hooks = [DedupingHook(FlagFilteringHook(ObservabilityHook(), flagKeys: myFlagKeys))] + ``` + + A decorator reports the wrapped hook's metadata as its own, so the SDK names the hook that a stage belongs to rather + than the wrappers around it. + + A decorator that suppresses a stage must suppress the whole evaluation series, because hooks pair their stages: an + observability hook opens a span in `beforeEvaluation` and closes it in `afterEvaluation`, so suppressing only the after + stage leaves that span open. To carry the decision from one stage to the other, return series data the after stage + recognizes, the way `DedupingHook` does. + + A decorator that does that belongs outermost, because the series data it returns replaces what it was given: a decorator + outside it does not get back what it stored in its own before stage. + + This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. It is + experimental. + */ +open class HookDecorator: Hook { + // Reachable only through `super`. Handing the wrapped hook back out invites code to unwrap a decorator and decide + // what to do with what it finds, when a decorator is meant to stand in for the hook it wraps. + private let delegate: Hook + + /// - parameter delegate: The hook to forward each stage to. + public init(_ delegate: Hook) { + self.delegate = delegate + } + + /// Returns the wrapped hook's metadata, so that the SDK names the hook a stage belongs to. + open func metadata() -> Metadata { + return delegate.metadata() + } + + /// Forwards the stage to the wrapped hook. + open func beforeEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData) -> EvaluationSeriesData { + return delegate.beforeEvaluation(seriesContext: seriesContext, seriesData: seriesData) + } + + /// Forwards the stage to the wrapped hook. + open func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail) -> EvaluationSeriesData { + return delegate.afterEvaluation(seriesContext: seriesContext, seriesData: seriesData, evaluationDetail: evaluationDetail) + } + + /// Forwards the stage to the wrapped hook. + open func beforeIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData) -> IdentifySeriesData { + return delegate.beforeIdentify(seriesContext: seriesContext, seriesData: seriesData) + } + + /// Forwards the stage to the wrapped hook. + open func afterIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData, result: IdentifyResult) -> IdentifySeriesData { + return delegate.afterIdentify(seriesContext: seriesContext, seriesData: seriesData, result: result) + } + + /// Forwards the stage to the wrapped hook. + open func afterTrack(seriesContext: TrackSeriesContext) { + delegate.afterTrack(seriesContext: seriesContext) + } +} diff --git a/LaunchDarkly/LaunchDarkly/UnfairLock.swift b/LaunchDarkly/LaunchDarkly/UnfairLock.swift new file mode 100644 index 00000000..e35bfa32 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/UnfairLock.swift @@ -0,0 +1,30 @@ +import Foundation + +#if canImport(Darwin) +/// A mutex that wraps Darwin's `os_unfair_lock`. The lock is held behind a pointer, because `os_unfair_lock` must not +/// be copied and a stored property would be. +final class UnfairLock { + private let unfairLock: UnsafeMutablePointer + + init() { + unfairLock = UnsafeMutablePointer.allocate(capacity: 1) + unfairLock.initialize(to: os_unfair_lock()) + } + + deinit { + unfairLock.deinitialize(count: 1) + unfairLock.deallocate() + } + + func lock() { + os_unfair_lock_lock(unfairLock) + } + + func unlock() { + os_unfair_lock_unlock(unfairLock) + } +} +#else +/// `os_unfair_lock` is Darwin only, so elsewhere this is Foundation's mutex. +typealias UnfairLock = NSLock +#endif diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientEvaluationHookSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientEvaluationHookSpec.swift index 633e95ca..5e0932dc 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientEvaluationHookSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientEvaluationHookSpec.swift @@ -97,9 +97,492 @@ final class LDClientEvaluationHookSpec: XCTestCase { XCTAssertEqual(data["before"] as! String, "was called") } + private func dedupeTestContext(hooks: [Hook]) -> TestContext { + var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled) + config.hooks = hooks + + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + testContext.flagStoreMock.replaceStore(newStoredItems: FlagMaintainingMock.stubStoredItems()) + return testContext + } + + // An evaluation reads the flag once, so every hook it reaches and the result it returns all describe that one read, + // whatever the store does in the meantime. + func testHooksAndTheResultDescribeOneReadOfTheFlag() { + var keys: [EvaluationExposureKey?] = [] + var emptyTheStore: (() -> Void)? + let firstHook = MockHook(before: { seriesContext, data in + keys.append(seriesContext.evaluationExposureKey) + emptyTheStore?() + return data + }, after: { _, data, _ in data }) + let secondHook = MockHook(before: { seriesContext, data in + keys.append(seriesContext.evaluationExposureKey) + return data + }, after: { _, data, _ in data }) + let testContext = dedupeTestContext(hooks: [firstHook, secondHook]) + emptyTheStore = { testContext.flagStoreMock.replaceStore(newStoredItems: [:]) } + + let value = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + + XCTAssertEqual(keys.count, 2) + XCTAssertEqual(keys[0]?.variation, 2) + XCTAssertEqual(keys[0], keys[1]) + XCTAssertTrue(value) + } + + func testAnEvaluationWithNoFlagDataIsDescribedByTheDefaultValue() { + var keys: [EvaluationExposureKey?] = [] + let hook = MockHook(before: { seriesContext, data in + keys.append(seriesContext.evaluationExposureKey) + return data + }, after: { _, data, _ in data }) + let testContext = dedupeTestContext(hooks: [hook]) + + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.unknown, defaultValue: true) + + // The evaluation returns the default value, and the event it records reports that value under no variation and + // no version, so that is the result the hook is told about too. + guard let key = keys.first ?? nil + else { + fail("The hook was not told about the evaluation.") + return + } + XCTAssertEqual(key.value, .bool(true)) + XCTAssertNil(key.variation) + XCTAssertNil(key.flagVersion) + } + + func testRepeatedEvaluationsReachAHookThatAskedForNoDedupe() { + var befores = 0 + var afters = 0 + let hook = MockHook(before: { _, data in befores += 1; return data }, after: { _, data, _ in afters += 1; return data }) + let testContext = dedupeTestContext(hooks: [hook]) + + for _ in 0..<3 { + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + } + + // Deduplication is opt-in per hook, and this one did not opt in. + XCTAssertEqual(befores, 3) + XCTAssertEqual(afters, 3) + } + + func testEvaluationsOfAFlagWithNoValueAreTheExposuresOfTheDefaultsTheyReturn() { + var afters = 0 + let hook = MockHook(before: { _, data in data }, after: { _, data, _ in afters += 1; return data }) + let testContext = dedupeTestContext(hooks: [DedupingHook(hook, window: 60)]) + // A flag that is off without an off variation arrives carrying no value, so every evaluation of it returns the + // default the caller passed. + let flagKey = "no-value-flag" + testContext.flagStoreMock.replaceStore(newStoredItems: StoredItems(items: [ + flagKey: FeatureFlag(flagKey: flagKey, value: .null, variation: 1, version: 2) + ])) + + XCTAssertTrue(testContext.subject.boolVariation(forKey: flagKey, defaultValue: true)) + XCTAssertFalse(testContext.subject.boolVariation(forKey: flagKey, defaultValue: false)) + + // The two evaluations returned different values, so they are two exposures. Describing both by the value the + // flag holds would make the second look like a repeat of the first. + XCTAssertEqual(afters, 2) + + // Repeating one of them is still a repeat. + XCTAssertFalse(testContext.subject.boolVariation(forKey: flagKey, defaultValue: false)) + XCTAssertEqual(afters, 2) + } + + func testRepeatedEvaluationsAreDeduplicatedWithinTheHooksWindow() { + var befores = 0 + var afters = 0 + let hook = MockHook(before: { _, data in befores += 1; return data }, after: { _, data, _ in afters += 1; return data }) + let testContext = dedupeTestContext(hooks: [DedupingHook(hook, window: 60)]) + + for _ in 0..<3 { + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + } + + // The whole series is skipped, so a hook pairing its stages never sees an unmatched before. + XCTAssertEqual(befores, 1) + XCTAssertEqual(afters, 1) + } + + func testDeduplicatedEvaluationsStillRecordEvents() { + let hook = MockHook(before: { _, data in data }, after: { _, data, _ in data }) + let testContext = dedupeTestContext(hooks: [DedupingHook(hook, window: 60)]) + + for _ in 0..<3 { + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + } + + // Deduplication applies to hooks only, so analytics events are unaffected. + XCTAssertEqual(testContext.eventReporterMock.recordFlagEvaluationEventsCallCount, 3) + } + + func testEvaluationsOfDifferentFlagsReachHooksSeparately() { + var afters = 0 + let hook = MockHook(before: { _, data in data }, after: { _, data, _ in afters += 1; return data }) + let testContext = dedupeTestContext(hooks: [DedupingHook(hook, window: 60)]) + + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + _ = testContext.subject.stringVariation(forKey: DarklyServiceMock.FlagKeys.string, defaultValue: DefaultFlagValues.string) + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + + XCTAssertEqual(afters, 2) + } + + func testEvaluationsReachHooksAgainAfterTheFlagChanges() { + var afters = 0 + let hook = MockHook(before: { _, data in data }, after: { _, data, _ in afters += 1; return data }) + let testContext = dedupeTestContext(hooks: [DedupingHook(hook, window: 60)]) + + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + XCTAssertEqual(afters, 1) + + let updated = FeatureFlag(flagKey: DarklyServiceMock.FlagKeys.bool, value: true, variation: 2, flagVersion: 99) + testContext.flagStoreMock.replaceStore(newStoredItems: [DarklyServiceMock.FlagKeys.bool: .item(updated)]) + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + + XCTAssertEqual(afters, 2) + } + + func testIdentifyLetsEveryHookObserveEvaluationsAgain() { + var afters = 0 + let hook = MockHook(before: { _, data in data }, after: { _, data, _ in afters += 1; return data }) + let testContext = dedupeTestContext(hooks: [DedupingHook(hook, window: 60)]) + + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + XCTAssertEqual(afters, 1) + + // Identifying to the unchanged context still lets the flag be reported again, so the evaluation after it is not + // suppressed. Restoring the store afterwards keeps the flag resolving to the same result it did before, which is + // what makes this a test of the identify rather than of a change to the flag. + waitUntil { done in + testContext.subject.identify(context: testContext.subject.context) { _ in done() } + } + testContext.flagStoreMock.replaceStore(newStoredItems: FlagMaintainingMock.stubStoredItems()) + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + + XCTAssertEqual(afters, 2) + } + + func testHooksWithDifferentWindowsSuppressIndependently() { + var noDedupe = 0 + var deduped = 0 + var zeroWindow = 0 + let noDedupeHook = MockHook(before: { _, data in data }, after: { _, data, _ in noDedupe += 1; return data }) + let dedupingHook = MockHook(before: { _, data in data }, after: { _, data, _ in deduped += 1; return data }) + let zeroWindowHook = MockHook(before: { _, data in data }, after: { _, data, _ in zeroWindow += 1; return data }) + let testContext = dedupeTestContext(hooks: [noDedupeHook, + DedupingHook(dedupingHook, window: 60), + DedupingHook(zeroWindowHook, window: 0)]) + + for _ in 0..<3 { + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + } + + // Only the hook wrapped in a window suppresses. Being unwrapped and being wrapped in a window of zero behave the + // same way. + XCTAssertEqual(noDedupe, 3) + XCTAssertEqual(deduped, 1) + XCTAssertEqual(zeroWindow, 3) + + // identify lets every wrapped hook be told about the flag again. + waitUntil { done in + testContext.subject.identify(context: testContext.subject.context) { _ in done() } + } + testContext.flagStoreMock.replaceStore(newStoredItems: FlagMaintainingMock.stubStoredItems()) + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + XCTAssertEqual(deduped, 2) + } + + func testHooksGivenSeparateDedupersDoNotSuppressEachOther() { + var first = 0 + var second = 0 + let firstHook = MockHook(before: { _, data in data }, after: { _, data, _ in first += 1; return data }) + let secondHook = MockHook(before: { _, data in data }, after: { _, data, _ in second += 1; return data }) + let testContext = dedupeTestContext(hooks: [DedupingHook(firstHook, window: 60), + DedupingHook(secondHook, window: 60)]) + + for _ in 0..<3 { + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + } + + // Sharing a deduper would let the first hook consume the window and leave the second hook with nothing. + XCTAssertEqual(first, 1) + XCTAssertEqual(second, 1) + } + + func testHooksSharingOneDeduperShareItsWindow() { + var first = 0 + var second = 0 + let shared = EvaluationExposureDeduper(window: 60) + let firstHook = MockHook(before: { _, data in data }, after: { _, data, _ in first += 1; return data }) + let secondHook = MockHook(before: { _, data in data }, after: { _, data, _ in second += 1; return data }) + let testContext = dedupeTestContext(hooks: [DedupingHook(firstHook, deduper: shared), + DedupingHook(secondHook, deduper: shared)]) + + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + + // The first hook's report starts the window, which suppresses the second hook's. + XCTAssertEqual(first, 1) + XCTAssertEqual(second, 0) + } + + func testACustomDeduperDecidesWhichEvaluationsReachItsHook() { + var afters = 0 + let hook = MockHook(before: { _, data in data }, after: { _, data, _ in afters += 1; return data }) + let deduper = CountingDeduper() + let testContext = dedupeTestContext(hooks: [DedupingHook(hook, deduper: deduper)]) + + for _ in 0..<4 { + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + } + + // The subclass reports every other evaluation, which the SDK's implementation would not. + XCTAssertEqual(afters, 2) + XCTAssertEqual(deduper.keys.count, 4) + XCTAssertEqual(Set(deduper.keys).count, 1) + XCTAssertEqual(deduper.keys[0].flagKey, DarklyServiceMock.FlagKeys.bool) + // A hook is told which environment an evaluation was made against, but not the credential that identifies it. + XCTAssertEqual(deduper.keys[0].mobileKeyHash, testContext.subject.mobileKeyHash) + XCTAssertNotEqual(deduper.keys[0].mobileKeyHash, "mobile-key") + } + + func testEnvironmentsSharingAHookDoNotSuppressEachOther() { + var afters = 0 + let hook = MockHook(before: { _, data in data }, after: { _, data, _ in afters += 1; return data }) + var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled) + config.hooks = [DedupingHook(hook, window: 60)] + try! config.setSecondaryMobileKeys(["other": "other-mobile-key"]) + + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + guard let other = LDClient.get(environment: "other") + else { + fail("The secondary environment's client was never created.") + return + } + for client in [testContext.subject, other] { + (client?.flagStore as? FlagMaintainingMock)?.replaceStore(newStoredItems: FlagMaintainingMock.stubStoredItems()) + } + + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + _ = other.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + + // Both environments resolve the flag identically, but the hook they share is told about each of them. + XCTAssertEqual(afters, 2) + } + + func testIdentifyResetsACustomDeduper() { + let hook = MockHook(before: { _, data in data }, after: { _, data, _ in data }) + let deduper = CountingDeduper() + let testContext = dedupeTestContext(hooks: [DedupingHook(hook, deduper: deduper)]) + + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + let resetsBeforeIdentify = deduper.resets + + waitUntil { done in + testContext.subject.identify(context: LDContext.stub()) { _ in done() } + } + + XCTAssertEqual(deduper.resets, resetsBeforeIdentify + 1) + } + + func testADeduperForwardsTheStagesItDoesNotDeduplicate() { + let hook = RecordingHook() + let testContext = dedupeTestContext(hooks: [DedupingHook(hook, window: 60)]) + _ = hook.takeStages() + + waitUntil { done in + testContext.subject.identify(context: LDContext.stub()) { _ in done() } + } + testContext.subject.track(key: "event-key") + + // Only evaluations are deduplicated; the identify and track stages are always forwarded. + XCTAssertEqual(hook.takeStages(), ["beforeIdentify", "afterIdentify", "afterTrack"]) + } + + func testADeduperReportsTheWrappedHooksMetadata() { + let hook = RecordingHook() + + // So that the SDK names the hook a stage belongs to rather than the wrapper around it. + XCTAssertTrue(DedupingHook(hook, window: 60).metadata() === hook.hookMetadata) + } + + func testADeduperForwardsAnEvaluationWhoseResultTheSdkDidNotDescribe() { + let hook = RecordingHook() + let deduping = DedupingHook(hook, window: 60) + // A series context built by something other than the SDK has no result to recognize repeats by, so nothing is + // suppressed. + let seriesContext = EvaluationSeriesContext(flagKey: "flag", context: LDContext.stub(), + defaultValue: .bool(false), methodName: "boolVariation") + let detail = LDEvaluationDetail(value: LDValue.bool(false), variationIndex: nil, reason: nil) + + for _ in 0..<2 { + let seriesData = deduping.beforeEvaluation(seriesContext: seriesContext, seriesData: [:]) + _ = deduping.afterEvaluation(seriesContext: seriesContext, seriesData: seriesData, evaluationDetail: detail) + } + + XCTAssertEqual(hook.takeStages(), + ["beforeEvaluation", "afterEvaluation", "beforeEvaluation", "afterEvaluation"]) + } + + func testADeduperStacksInsideAnotherDecorator() { + var afters = 0 + let hook = MockHook(before: { _, data in data }, after: { _, data, _ in afters += 1; return data }) + let counting = CountingDecorator(DedupingHook(hook, window: 60)) + let testContext = dedupeTestContext(hooks: [counting]) + + for _ in 0..<3 { + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + } + + // The outer decorator sees every evaluation, and the deduper inside it passes on one. This is the arrangement + // the documentation advises against, and it works as long as the decorator outside the deduper does not store + // series data, which a suppressed evaluation replaces. + XCTAssertEqual(counting.evaluationsForwarded, 3) + XCTAssertEqual(afters, 1) + } + + func testADeduperStacksAroundAnotherDecorator() { + var afters = 0 + let hook = MockHook(before: { _, data in data }, after: { _, data, _ in afters += 1; return data }) + let counting = CountingDecorator(hook) + let testContext = dedupeTestContext(hooks: [DedupingHook(counting, window: 60)]) + + for _ in 0..<3 { + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + } + + // The deduper is outermost this time, so the decorator inside it sees only what it forwards. + XCTAssertEqual(counting.evaluationsForwarded, 1) + XCTAssertEqual(afters, 1) + } + + func testADeduperDoesNotSwallowTheStagesOfADeduperInsideIt() { + var afters = 0 + let hook = MockHook(before: { _, data in data }, after: { _, data, _ in afters += 1; return data }) + let counting = CountingDecorator(DedupingHook(hook, window: 60)) + // The outer deduper reports everything, so what the inner one suppresses has to travel back out through the + // decorator between them, which each stage of still belongs to. + let testContext = dedupeTestContext(hooks: [DedupingHook(counting, window: 0)]) + + for _ in 0..<3 { + _ = testContext.subject.boolVariation(forKey: DarklyServiceMock.FlagKeys.bool, defaultValue: DefaultFlagValues.bool) + } + + XCTAssertEqual(counting.evaluationsForwarded, 3) + XCTAssertEqual(counting.resultsForwarded, 3) + XCTAssertEqual(afters, 1) + } + + func testADecoratorForwardsTheStagesItDoesNotOverride() { + let hook = RecordingHook() + let testContext = dedupeTestContext(hooks: [CountingDecorator(hook)]) + _ = hook.takeStages() + + waitUntil { done in + testContext.subject.identify(context: LDContext.stub()) { _ in done() } + } + testContext.subject.track(key: "event-key") + + // The decorator mentions neither identify nor track, and the hook it wraps is still told about both: a stage a + // decorator leaves alone is forwarded rather than dropped. + XCTAssertEqual(hook.takeStages(), ["beforeIdentify", "afterIdentify", "afterTrack"]) + } + + /// A hook that wraps another hook and counts what it forwards, to check that wrappers compose. + /// + /// Overrides only the evaluation stages, as a wrapper a customer writes would: the rest are inherited. + class CountingDecorator: HookDecorator { + private(set) var evaluationsForwarded = 0 + private(set) var resultsForwarded = 0 + + override func beforeEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData) -> EvaluationSeriesData { + evaluationsForwarded += 1 + return super.beforeEvaluation(seriesContext: seriesContext, seriesData: seriesData) + } + + override func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail) -> EvaluationSeriesData { + resultsForwarded += 1 + return super.afterEvaluation(seriesContext: seriesContext, seriesData: seriesData, evaluationDetail: evaluationDetail) + } + } + typealias BeforeHook = (_: EvaluationSeriesContext, _: EvaluationSeriesData) -> EvaluationSeriesData typealias AfterHook = (_: EvaluationSeriesContext, _: EvaluationSeriesData, _: LDEvaluationDetail) -> EvaluationSeriesData + /// Reports every other evaluation, so that it can be told apart from both of the dedupers the SDK provides. + class CountingDeduper: EvaluationExposureDeduper { + private(set) var keys: [EvaluationExposureKey] = [] + private(set) var resets = 0 + + init() { + super.init(window: 0) + } + + override func shouldRecord(key: EvaluationExposureKey, now: TimeInterval = EvaluationExposureDeduper.monotonicNow()) -> Bool { + keys.append(key) + return keys.count % 2 == 1 + } + + override func reset() { + resets += 1 + } + } + + /// Records the stages it is told about, so a test can check what a hook wrapping it forwarded. + class RecordingHook: Hook { + // Returned rather than built on demand, so that a test can recognize it by identity: `Metadata` exposes + // nothing to compare it by. + let hookMetadata = Metadata(name: "recording-hook") + private var stages: [String] = [] + + /// The stages recorded since this was last called, so a test can ignore the identify the client makes as it + /// starts. + func takeStages() -> [String] { + defer { stages = [] } + return stages + } + + func metadata() -> Metadata { + return hookMetadata + } + + func beforeEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData) -> EvaluationSeriesData { + stages.append("beforeEvaluation") + return seriesData + } + + func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail) -> EvaluationSeriesData { + stages.append("afterEvaluation") + return seriesData + } + + func beforeIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData) -> IdentifySeriesData { + stages.append("beforeIdentify") + return seriesData + } + + func afterIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData, result: IdentifyResult) -> IdentifySeriesData { + stages.append("afterIdentify") + return seriesData + } + + func afterTrack(seriesContext: TrackSeriesContext) { + stages.append("afterTrack") + } + } + class MockHook: Hook { let before: BeforeHook let after: AfterHook diff --git a/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EvaluationExposureDeduperSpec.swift b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EvaluationExposureDeduperSpec.swift new file mode 100644 index 00000000..0315705d --- /dev/null +++ b/LaunchDarkly/LaunchDarklyTests/ServiceObjects/EvaluationExposureDeduperSpec.swift @@ -0,0 +1,226 @@ +import Foundation +import Quick +import Nimble +@testable import LaunchDarkly + +/// An exposure key that differs from every other one this spec builds only by its flag key, so that a test can talk +/// about "the exposure of a" without spelling out the whole key. +private func key(_ flagKey: LDFlagKey) -> EvaluationExposureKey { + return EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", + flagKey: flagKey, + variation: 1, + flagVersion: 2, + fullyQualifiedContextKey: "user-key", + value: .string("value")) +} + +/// The same flag as `key(_:)`, resolved to a different variation. +private func otherResult(_ flagKey: LDFlagKey) -> EvaluationExposureKey { + return EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", + flagKey: flagKey, + variation: 3, + flagVersion: 2, + fullyQualifiedContextKey: "user-key", + value: .string("other-value")) +} + +/// The key the SDK builds for an evaluation it has no flag data for: one made before the client has flags, or one of a +/// flag that does not exist. Such an evaluation returns the default value, so that is the value describing it, with no +/// variation and no version. +private func unknownFlag(_ flagKey: LDFlagKey, _ defaultValue: LDValue) -> EvaluationExposureKey { + return EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", + flagKey: flagKey, + variation: nil, + flagVersion: nil, + fullyQualifiedContextKey: "user-key", + value: defaultValue) +} + +final class EvaluationExposureDeduperSpec: QuickSpec { + override func spec() { + describe("EvaluationExposureDeduper") { + it("records everything for a non-positive window") { + for window: TimeInterval in [0, -1] { + let deduper = EvaluationExposureDeduper(window: window) + expect(deduper.shouldRecord(key: key("a"), now: 0)) == true + expect(deduper.shouldRecord(key: key("a"), now: 0)) == true + } + } + it("suppresses repeats within the window") { + let deduper = EvaluationExposureDeduper(window: 10) + expect(deduper.shouldRecord(key: key("a"), now: 1_000)) == true + expect(deduper.shouldRecord(key: key("a"), now: 1_000)) == false + expect(deduper.shouldRecord(key: key("a"), now: 1_009)) == false + } + it("records again once the window elapses") { + let deduper = EvaluationExposureDeduper(window: 10) + expect(deduper.shouldRecord(key: key("a"), now: 1_000)) == true + expect(deduper.shouldRecord(key: key("a"), now: 1_010)) == true + // Recording restarts the window rather than extending the original one. + expect(deduper.shouldRecord(key: key("a"), now: 1_015)) == false + expect(deduper.shouldRecord(key: key("a"), now: 1_020)) == true + } + it("applies a sub-second window") { + let deduper = EvaluationExposureDeduper(window: 0.5) + expect(deduper.shouldRecord(key: key("a"), now: 1_000)) == true + expect(deduper.shouldRecord(key: key("a"), now: 1_000.4)) == false + expect(deduper.shouldRecord(key: key("a"), now: 1_000.5)) == true + } + it("tracks flags independently") { + let deduper = EvaluationExposureDeduper(window: 10) + expect(deduper.shouldRecord(key: key("a"), now: 1_000)) == true + expect(deduper.shouldRecord(key: key("b"), now: 1_000)) == true + expect(deduper.shouldRecord(key: key("a"), now: 1_000)) == false + expect(deduper.shouldRecord(key: key("b"), now: 1_000)) == false + } + it("reports a flag again as soon as its result changes") { + let deduper = EvaluationExposureDeduper(window: 10) + expect(deduper.shouldRecord(key: key("a"), now: 1_000)) == true + expect(deduper.shouldRecord(key: otherResult("a"), now: 1_001)) == true + expect(deduper.shouldRecord(key: otherResult("a"), now: 1_002)) == false + // Only the result the flag reported last is tracked, so flipping back is a change too and the hook is + // told about it rather than being left to think the flag never returned to it. + expect(deduper.shouldRecord(key: key("a"), now: 1_003)) == true + expect(deduper.shouldRecord(key: key("a"), now: 1_004)) == false + } + it("reports again when only the flag value changes") { + let deduper = EvaluationExposureDeduper(window: 10) + let first = EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "flag", variation: 1, + flagVersion: 2, + fullyQualifiedContextKey: "user-key", value: .string("first")) + let second = EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "flag", variation: 1, + flagVersion: 2, + fullyQualifiedContextKey: "user-key", value: .string("second")) + + expect(deduper.shouldRecord(key: first, now: 1_000)) == true + expect(deduper.shouldRecord(key: second, now: 1_001)) == true + expect(deduper.shouldRecord(key: second, now: 1_002)) == false + } + it("treats evaluations with no flag data resolving to the same default as one exposure") { + let deduper = EvaluationExposureDeduper(window: 10) + + // Evaluations the SDK has no flag data for return the default value, and repeats of that result are a + // repeat like any other. + expect(deduper.shouldRecord(key: unknownFlag("a", .bool(false)), now: 1_000)) == true + expect(deduper.shouldRecord(key: unknownFlag("a", .bool(false)), now: 1_001)) == false + + // A different default is a different result, because it is a different value returned to the + // application. + expect(deduper.shouldRecord(key: unknownFlag("a", .bool(true)), now: 1_002)) == true + expect(deduper.shouldRecord(key: unknownFlag("a", .bool(true)), now: 1_003)) == false + } + it("reports again when the flag becomes known") { + let deduper = EvaluationExposureDeduper(window: 10) + expect(deduper.shouldRecord(key: unknownFlag("a", .string("value")), now: 1_000)) == true + + // The data arriving is a change of result even when the flag resolves to the value the default had + // already produced, because the evaluation now has a variation and a version. So is the flag going away + // again. + expect(deduper.shouldRecord(key: key("a"), now: 1_001)) == true + expect(deduper.shouldRecord(key: key("a"), now: 1_002)) == false + expect(deduper.shouldRecord(key: unknownFlag("a", .string("value")), now: 1_003)) == true + } + it("tracks the same flag separately per environment") { + let deduper = EvaluationExposureDeduper(window: 10) + let primary = EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "flag", variation: 1, + flagVersion: 2, + fullyQualifiedContextKey: "user-key") + let secondary = EvaluationExposureKey(mobileKeyHash: "other-mobile-key-hash", flagKey: "flag", variation: 3, + flagVersion: 4, + fullyQualifiedContextKey: "user-key") + + // A hook set on the configuration is shared by the clients for every environment, so its deduper sees + // both. Neither environment may look to the other like its result changing. + expect(deduper.shouldRecord(key: primary, now: 1_000)) == true + expect(deduper.shouldRecord(key: secondary, now: 1_000)) == true + expect(deduper.shouldRecord(key: primary, now: 1_001)) == false + expect(deduper.shouldRecord(key: secondary, now: 1_001)) == false + } + it("records again after reset") { + let deduper = EvaluationExposureDeduper(window: 10) + expect(deduper.shouldRecord(key: key("a"), now: 1_000)) == true + deduper.reset() + expect(deduper.shouldRecord(key: key("a"), now: 1_000)) == true + } + it("uses the default window when built without one") { + // Ten minutes. + expect(EvaluationExposureDeduper.defaultWindow) == 600 + + let deduper = EvaluationExposureDeduper() + expect(deduper.shouldRecord(key: key("a"), now: 1_000)) == true + expect(deduper.shouldRecord(key: key("a"), now: 1_599)) == false + expect(deduper.shouldRecord(key: key("a"), now: 1_600)) == true + } + it("records once when the same key is checked concurrently") { + let deduper = EvaluationExposureDeduper(window: 10) + let counting = DispatchQueue(label: "EvaluationExposureDeduperSpec.recorded") + var recorded = 0 + + DispatchQueue.concurrentPerform(iterations: 10) { _ in + if deduper.shouldRecord(key: key("a"), now: 1_000) { + counting.sync { recorded += 1 } + } + } + + // The check and the update are performed together, so concurrent evaluations of one flag cannot both + // be told to record. + expect(recorded) == 1 + } + it("tracks every flag the application evaluates") { + let deduper = EvaluationExposureDeduper(window: 600) + for i in 0..<2_000 { + expect(deduper.shouldRecord(key: key("key-\(i)"), now: 1_000)) == true + } + + // Records accumulate; the first flag is still suppressed after two thousand others have been recorded. + expect(deduper.shouldRecord(key: key("key-0"), now: 1_000)) == false + expect(deduper.shouldRecord(key: key("key-1999"), now: 1_000)) == false + } + } + describe("EvaluationExposureKey") { + let base = EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "flag", variation: 1, + flagVersion: 2, fullyQualifiedContextKey: "user-key") + it("distinguishes every component") { + expect(base) == EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "flag", variation: 1, + flagVersion: 2, + fullyQualifiedContextKey: "user-key") + expect(base) != EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "flag", variation: 1, + flagVersion: 2, + fullyQualifiedContextKey: "user-key", value: .string("value")) + expect(base) != EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "other-flag", variation: 1, + flagVersion: 2, + fullyQualifiedContextKey: "user-key") + expect(base) != EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "flag", variation: 3, + flagVersion: 2, + fullyQualifiedContextKey: "user-key") + expect(base) != EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "flag", variation: 1, + flagVersion: 4, + fullyQualifiedContextKey: "user-key") + expect(base) != EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "flag", variation: 1, + flagVersion: 2, + fullyQualifiedContextKey: "other-user-key") + // A hook shared across environments observes the same result once per environment. + expect(base) != EvaluationExposureKey(mobileKeyHash: "other-mobile-key-hash", flagKey: "flag", variation: 1, + flagVersion: 2, + fullyQualifiedContextKey: "user-key") + } + it("tells a missing variation and version apart from any present one") { + let missing = EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "flag", variation: nil, + flagVersion: nil, + fullyQualifiedContextKey: "user-key") + expect(missing) != base + expect(missing) == EvaluationExposureKey(mobileKeyHash: "mobile-key-hash", flagKey: "flag", variation: nil, + flagVersion: nil, + fullyQualifiedContextKey: "user-key") + } + it("tells evaluations with no flag data apart by the default they returned") { + let key = unknownFlag("flag", .bool(false)) + let same = unknownFlag("flag", .bool(false)) + expect(key) == same + expect(key.hashValue) == same.hashValue + + expect(key) != unknownFlag("flag", .bool(true)) + } + } + } +}