diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 847a829e13..2d9dedb7c3 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -1152,6 +1152,10 @@ 96F25A822CC7EA4400459567 /* SessionReplayPrivacyOverrides+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F25A802CC7EA4300459567 /* SessionReplayPrivacyOverrides+objc.swift */; }; 96F25A832CC7EA4400459567 /* UIView+SessionReplayPrivacyOverrides+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F25A812CC7EA4300459567 /* UIView+SessionReplayPrivacyOverrides+objc.swift */; }; 96F69D6C2CBE94A800A6178B /* DatadogCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */; }; + 282EF63D069B9056BD53AF91 /* RemoteSamplingSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852FB55E25A09C9D285F7114 /* RemoteSamplingSnapshotTests.swift */; }; + 12D14979916A036A2CC36DBE /* RemoteSamplingSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852FB55E25A09C9D285F7114 /* RemoteSamplingSnapshotTests.swift */; }; + 0E81AE3C56D66A15CCF3B697 /* RemoteSamplingControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D9E6D53F2A132243281BF3 /* RemoteSamplingControllerTests.swift */; }; + 5CEEC145E560188306A44870 /* RemoteSamplingControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D9E6D53F2A132243281BF3 /* RemoteSamplingControllerTests.swift */; }; 96F69D6D2CBE94A900A6178B /* DatadogCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */; }; 96F70D452DD793C400D3736B /* RUMAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F70D442DD793C400D3736B /* RUMAction.swift */; }; 96F70D462DD793C400D3736B /* RUMAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F70D442DD793C400D3736B /* RUMAction.swift */; }; @@ -1297,6 +1301,8 @@ D21C26D228A64599005DD405 /* MessageBusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21C26D028A64599005DD405 /* MessageBusTests.swift */; }; D22442C52CA301DA002E71E4 /* UIColor+SessionReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22442C42CA301DA002E71E4 /* UIColor+SessionReplay.swift */; }; D224430429E9588100274EC7 /* TelemetryReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D214DAA729E54CB4004D0AE8 /* TelemetryReceiver.swift */; }; + 02A7E6777D3C038F8CF2AC39 /* RemoteSamplingReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF7CAEB638798A0BBDA46CB /* RemoteSamplingReceiver.swift */; }; + 8BB6657E07CFDC5C252B17E1 /* RemoteSamplingReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF7CAEB638798A0BBDA46CB /* RemoteSamplingReceiver.swift */; }; D224430529E9588500274EC7 /* TelemetryReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D214DAA729E54CB4004D0AE8 /* TelemetryReceiver.swift */; }; D224430629E95C2C00274EC7 /* MessageBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D214DAA429E072D7004D0AE8 /* MessageBus.swift */; }; D224430729E95C2E00274EC7 /* MessageBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D214DAA429E072D7004D0AE8 /* MessageBus.swift */; }; @@ -1485,6 +1491,8 @@ D23F8E8029DDCD28001CFAE8 /* VitalInfoSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E9973F0268DF69500D8059B /* VitalInfoSampler.swift */; }; D23F8E8129DDCD28001CFAE8 /* RUMViewScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C21124C5951400C0321C /* RUMViewScope.swift */; }; D23F8E8229DDCD28001CFAE8 /* RUMSessionScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */; }; + 20EDC40A8B14129FB3E4928B /* RUMDrawnConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B495300E06863B82B49362BB /* RUMDrawnConfiguration.swift */; }; + 32989812C6103BA00321C225 /* RUMDrawnConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B495300E06863B82B49362BB /* RUMDrawnConfiguration.swift */; }; D23F8E8329DDCD28001CFAE8 /* RUMUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614B0A4A24EBC43D00A2A780 /* RUMUser.swift */; }; D23F8E8429DDCD28001CFAE8 /* UIKitRUMUserActionsPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F637AED12697404200516F32 /* UIKitRUMUserActionsPredicate.swift */; }; D23F8E8529DDCD28001CFAE8 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FCA238271D896E0020286F /* SwiftUIExtensions.swift */; }; @@ -1497,6 +1505,8 @@ D23F8E8F29DDCD28001CFAE8 /* RUMUUIDGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618DCFD824C7269500589570 /* RUMUUIDGenerator.swift */; }; D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A614E9276B9D4C00A06CE7 /* RUMOffViewEventsHandlingRuleTests.swift */; }; D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C2C20824C0C75500C0321C /* RUMSessionScopeTests.swift */; }; + DE687A414FCC3A4D328ABC7E /* RUMDrawnConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C01AEEC8C09EEE28EC2CA9A /* RUMDrawnConfigurationTests.swift */; }; + A36A655187BCCE5680EC5984 /* RUMDrawnConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C01AEEC8C09EEE28EC2CA9A /* RUMDrawnConfigurationTests.swift */; }; D23F8EA329DDCD38001CFAE8 /* RUMUserActionScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617CD0DC24CEDDD300B0B557 /* RUMUserActionScopeTests.swift */; }; D23F8EA629DDCD38001CFAE8 /* RUMDeviceInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FD9FCE28534EBD00214BD9 /* RUMDeviceInfoTests.swift */; }; D23F8EA829DDCD38001CFAE8 /* RUMResourceScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61494CB424C864680082C633 /* RUMResourceScopeTests.swift */; }; @@ -1508,6 +1518,8 @@ D23F8EB129DDCD38001CFAE8 /* RUMViewScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6198D27024C6E3B700493501 /* RUMViewScopeTests.swift */; }; D23F8EB229DDCD38001CFAE8 /* ValuePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611529AD25E3E429004F740E /* ValuePublisherTests.swift */; }; D23F8EB329DDCD38001CFAE8 /* ErrorMessageReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */; }; + B1A879D1C2D68AD9AA4BB1AE /* RemoteSamplingReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438D79D4085E0220A732CB84 /* RemoteSamplingReceiverTests.swift */; }; + 224D398714B6DD75DD8044E9 /* RemoteSamplingReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438D79D4085E0220A732CB84 /* RemoteSamplingReceiverTests.swift */; }; D23F8EB429DDCD38001CFAE8 /* RUMApplicationScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617B953F24BF4DB300E6F443 /* RUMApplicationScopeTests.swift */; }; D23F8EB629DDCD38001CFAE8 /* RUMViewsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29889C72734136200A4D1A9 /* RUMViewsHandlerTests.swift */; }; D23F8EB829DDCD38001CFAE8 /* RUMActionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C3195251DD5080018781C /* RUMActionsHandlerTests.swift */; }; @@ -1808,6 +1820,10 @@ D2B3F0442823EE8400C2B5EE /* TLVBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F0432823EE8300C2B5EE /* TLVBlockTests.swift */; }; D2B3F0452823EE8400C2B5EE /* TLVBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F0432823EE8300C2B5EE /* TLVBlockTests.swift */; }; D2B3F04D282A85FD00C2B5EE /* DatadogCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */; }; + FF1587B3CF5C9AC1CA800DAF /* RemoteSamplingSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E68372248A363CCDE4E8A00 /* RemoteSamplingSnapshot.swift */; }; + 3B8115C1224250F4CD369D23 /* RemoteSamplingSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E68372248A363CCDE4E8A00 /* RemoteSamplingSnapshot.swift */; }; + F7143B3DEDFBE78C328A89B9 /* RemoteSamplingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35FC2D438DB5010967AD2C /* RemoteSamplingController.swift */; }; + 6DF401C28146329A1EBE3761 /* RemoteSamplingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35FC2D438DB5010967AD2C /* RemoteSamplingController.swift */; }; D2B3F04E282A85FD00C2B5EE /* DatadogCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */; }; D2B3F052282E827700C2B5EE /* DDHTTPHeadersWriter+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F051282E826A00C2B5EE /* DDHTTPHeadersWriter+apiTests.m */; }; D2B3F053282E827B00C2B5EE /* DDHTTPHeadersWriter+apiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D2B3F051282E826A00C2B5EE /* DDHTTPHeadersWriter+apiTests.m */; }; @@ -2080,6 +2096,8 @@ D2DC4BF627F484AA00E4FB96 /* DataEncryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DC4BF527F484AA00E4FB96 /* DataEncryption.swift */; }; D2DC4BF727F484AA00E4FB96 /* DataEncryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DC4BF527F484AA00E4FB96 /* DataEncryption.swift */; }; D2DE63532A30A7CA00441A54 /* CoreRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DE63522A30A7CA00441A54 /* CoreRegistry.swift */; }; + 70B102EEA2DD2A8186D31B62 /* RemoteSampling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 784964956777BCDE3B3162A7 /* RemoteSampling.swift */; }; + 3CBF72DC4C4F98B6205D5576 /* RemoteSampling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 784964956777BCDE3B3162A7 /* RemoteSampling.swift */; }; D2DE63542A30A7CA00441A54 /* CoreRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DE63522A30A7CA00441A54 /* CoreRegistry.swift */; }; D2E6E8FB2D8039BB00FF1398 /* BenchmarkURLSessionTaskDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2E6E8FA2D8039B200FF1398 /* BenchmarkURLSessionTaskDelegate.swift */; }; D2E6E8FC2D8039BB00FF1398 /* BenchmarkURLSessionTaskDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2E6E8FA2D8039B200FF1398 /* BenchmarkURLSessionTaskDelegate.swift */; }; @@ -3237,6 +3255,8 @@ 614B0A4A24EBC43D00A2A780 /* RUMUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMUser.swift; sourceTree = ""; }; 614B0A4E24EBDC6B00A2A780 /* RUMConnectivityInfoProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMConnectivityInfoProvider.swift; sourceTree = ""; }; 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatadogCoreTests.swift; sourceTree = ""; }; + 852FB55E25A09C9D285F7114 /* RemoteSamplingSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingSnapshotTests.swift; sourceTree = ""; }; + C7D9E6D53F2A132243281BF3 /* RemoteSamplingControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingControllerTests.swift; sourceTree = ""; }; 614B78EC296D7B63009C6B92 /* LowPowerModePublisherTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LowPowerModePublisherTests.swift; sourceTree = ""; }; 614CADD62510BAC000B93D2D /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; 615192CC2BD6948B0005A782 /* HTTPHeadersWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeadersWriterTests.swift; sourceTree = ""; }; @@ -3368,7 +3388,9 @@ 61BBD19624ED50040023E65F /* DatadogConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogConfigurationTests.swift; sourceTree = ""; }; 61C1510C25AC8C1B00362D4B /* ViewIdentifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewIdentifierTests.swift; sourceTree = ""; }; 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMSessionScope.swift; sourceTree = ""; }; + B495300E06863B82B49362BB /* RUMDrawnConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMDrawnConfiguration.swift; sourceTree = ""; }; 61C2C20824C0C75500C0321C /* RUMSessionScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMSessionScopeTests.swift; sourceTree = ""; }; + 4C01AEEC8C09EEE28EC2CA9A /* RUMDrawnConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMDrawnConfigurationTests.swift; sourceTree = ""; }; 61C2C21124C5951400C0321C /* RUMViewScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMViewScope.swift; sourceTree = ""; }; 61C3637F2436164B00C4D4E6 /* ObjcExceptionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjcExceptionHandlerTests.swift; sourceTree = ""; }; 61C3E63624BF191F008053F2 /* RUMScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMScope.swift; sourceTree = ""; }; @@ -3595,6 +3617,7 @@ D213532F270CA722000315AD /* DataCompressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCompressionTests.swift; sourceTree = ""; }; D214DAA429E072D7004D0AE8 /* MessageBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBus.swift; sourceTree = ""; }; D214DAA729E54CB4004D0AE8 /* TelemetryReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryReceiver.swift; sourceTree = ""; }; + 1DF7CAEB638798A0BBDA46CB /* RemoteSamplingReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingReceiver.swift; sourceTree = ""; }; D215ED6A29D2E1080046B721 /* ErrorMessageReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageReceiver.swift; sourceTree = ""; }; D2160C9429C0DE5600FAA9A5 /* FirstPartyHosts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirstPartyHosts.swift; sourceTree = ""; }; D2160C9629C0DE5600FAA9A5 /* TracingHeaderType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TracingHeaderType.swift; sourceTree = ""; }; @@ -3619,6 +3642,7 @@ D21C26D028A64599005DD405 /* MessageBusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBusTests.swift; sourceTree = ""; }; D21C26EA28AFA11E005DD405 /* LogMessageReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogMessageReceiverTests.swift; sourceTree = ""; }; D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessageReceiverTests.swift; sourceTree = ""; }; + 438D79D4085E0220A732CB84 /* RemoteSamplingReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingReceiverTests.swift; sourceTree = ""; }; D22442C42CA301DA002E71E4 /* UIColor+SessionReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+SessionReplay.swift"; sourceTree = ""; }; D224430C29E95D6600274EC7 /* CrashReportReceiverTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CrashReportReceiverTests.swift; sourceTree = ""; }; D22689C22EB12D3D00875E44 /* KSCrashPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KSCrashPlugin.swift; sourceTree = ""; }; @@ -3811,6 +3835,8 @@ D2B249962A45E10500DD4F9F /* LoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerTests.swift; sourceTree = ""; }; D2B3F0432823EE8300C2B5EE /* TLVBlockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLVBlockTests.swift; sourceTree = ""; }; D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogCore.swift; sourceTree = ""; }; + 0E68372248A363CCDE4E8A00 /* RemoteSamplingSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingSnapshot.swift; sourceTree = ""; }; + 7D35FC2D438DB5010967AD2C /* RemoteSamplingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSamplingController.swift; sourceTree = ""; }; D2B3F051282E826A00C2B5EE /* DDHTTPHeadersWriter+apiTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "DDHTTPHeadersWriter+apiTests.m"; sourceTree = ""; }; D2BCB11E29D30AF000737A9A /* URLSessionRUMResourcesHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionRUMResourcesHandler.swift; sourceTree = ""; }; D2BCB12129D34A5F00737A9A /* URLSessionRUMResourcesHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionRUMResourcesHandlerTests.swift; sourceTree = ""; }; @@ -3865,6 +3891,7 @@ D2DA23C3298D59DC00C6C7E6 /* DatadogInternalTests tvOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "DatadogInternalTests tvOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D2DC4BF527F484AA00E4FB96 /* DataEncryption.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataEncryption.swift; sourceTree = ""; }; D2DE63522A30A7CA00441A54 /* CoreRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreRegistry.swift; sourceTree = ""; }; + 784964956777BCDE3B3162A7 /* RemoteSampling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSampling.swift; sourceTree = ""; }; D2E6E8FA2D8039B200FF1398 /* BenchmarkURLSessionTaskDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BenchmarkURLSessionTaskDelegate.swift; sourceTree = ""; }; D2E8A8E62DCBBA5100CF7C63 /* RUMCoreContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMCoreContext.swift; sourceTree = ""; }; D2E8D59728C7AB90007E5DE1 /* ContextMessageReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMessageReceiverTests.swift; sourceTree = ""; }; @@ -5541,6 +5568,7 @@ 61133B9E2423979B00786299 /* Core */ = { isa = PBXGroup; children = ( + F1A5E11A0000000000000A01 /* RemoteSampling */, D2B3F04C282A85FD00C2B5EE /* DatadogCore.swift */, D214DAA429E072D7004D0AE8 /* MessageBus.swift */, D2EFA866286DA82700F1FAA6 /* Context */, @@ -6025,6 +6053,7 @@ D236BE2729520FED00676E67 /* CrashReportReceiver.swift */, D215ED6A29D2E1080046B721 /* ErrorMessageReceiver.swift */, D214DAA729E54CB4004D0AE8 /* TelemetryReceiver.swift */, + 1DF7CAEB638798A0BBDA46CB /* RemoteSamplingReceiver.swift */, 61DCC84D2C071DCD00CB59E5 /* TelemetryInterceptor.swift */, D2D748222DC0FF7E00C61353 /* FatalErrorContextNotifier.swift */, 5B1D02842E8EB78600AB2391 /* FlagEvaluationReceiver.swift */, @@ -6273,6 +6302,7 @@ 617B953F24BF4DB300E6F443 /* RUMApplicationScopeTests.swift */, 61494CB424C864680082C633 /* RUMResourceScopeTests.swift */, 61C2C20824C0C75500C0321C /* RUMSessionScopeTests.swift */, + 4C01AEEC8C09EEE28EC2CA9A /* RUMDrawnConfigurationTests.swift */, 617CD0DC24CEDDD300B0B557 /* RUMUserActionScopeTests.swift */, 6198D27024C6E3B700493501 /* RUMViewScopeTests.swift */, 6141CE652806B3F200EBB879 /* Utils */, @@ -6425,6 +6455,7 @@ 61494CB024C839460082C633 /* RUMResourceScope.swift */, 6122514727FDFF82004F5AE4 /* RUMScopeDependencies.swift */, 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */, + B495300E06863B82B49362BB /* RUMDrawnConfiguration.swift */, 61494CB924CB126F0082C633 /* RUMUserActionScope.swift */, 61C2C21124C5951400C0321C /* RUMViewScope.swift */, 61494B7827F3522C0082BBCC /* Utils */, @@ -7062,6 +7093,7 @@ isa = PBXGroup; children = ( D2DE63522A30A7CA00441A54 /* CoreRegistry.swift */, + 784964956777BCDE3B3162A7 /* RemoteSampling.swift */, D23039B1298D5235001A1FA3 /* DatadogCoreProtocol.swift */, D23039BD298D5235001A1FA3 /* DatadogFeature.swift */, D23039AD298D5234001A1FA3 /* DD.swift */, @@ -7209,6 +7241,7 @@ A7E6EA832D314A9900997201 /* AnonymousIdentifierManagerTests.swift */, 615950EA291C029700470E0C /* SessionReplayDependencyTests.swift */, D21C26ED28AFB65B005DD405 /* ErrorMessageReceiverTests.swift */, + 438D79D4085E0220A732CB84 /* RemoteSamplingReceiverTests.swift */, 9E53889B2773C4B300A7DC42 /* WebViewEventReceiverTests.swift */, D248ED4728081B9B00B315B4 /* TelemetryReceiverTests.swift */, 61C453492C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift */, @@ -7746,9 +7779,28 @@ path = Context; sourceTree = ""; }; + F1A5E11A0000000000000A01 /* RemoteSampling */ = { + isa = PBXGroup; + children = ( + 7D35FC2D438DB5010967AD2C /* RemoteSamplingController.swift */, + 0E68372248A363CCDE4E8A00 /* RemoteSamplingSnapshot.swift */, + ); + path = RemoteSampling; + sourceTree = ""; + }; + F1A5E11A0000000000000A02 /* RemoteSampling */ = { + isa = PBXGroup; + children = ( + C7D9E6D53F2A132243281BF3 /* RemoteSamplingControllerTests.swift */, + 852FB55E25A09C9D285F7114 /* RemoteSamplingSnapshotTests.swift */, + ); + path = RemoteSampling; + sourceTree = ""; + }; D2EFA873286E010100F1FAA6 /* DatadogCore */ = { isa = PBXGroup; children = ( + F1A5E11A0000000000000A02 /* RemoteSampling */, 617699162A8608C20030022B /* Context */, 614B78EA296D7B63009C6B92 /* DatadogCoreTests.swift */, 6167E70D2B83502200C3CA2D /* DatadogCore+FeatureDirectoriesTests.swift */, @@ -9708,6 +9760,8 @@ 61F930C22BA1C41A005F0EE2 /* TLVBlockReader.swift in Sources */, 613E793B2577B6EE00DFCC17 /* DataReader.swift in Sources */, D2B3F04D282A85FD00C2B5EE /* DatadogCore.swift in Sources */, + 3B8115C1224250F4CD369D23 /* RemoteSamplingSnapshot.swift in Sources */, + 6DF401C28146329A1EBE3761 /* RemoteSamplingController.swift in Sources */, A731B7E22EE08FBE003D1E4F /* ContextSharingTransformer.swift in Sources */, 2671348E2D688AD60048CB54 /* AccountInfoPublisher.swift in Sources */, 61133BD62423979B00786299 /* DataUploader.swift in Sources */, @@ -9791,6 +9845,8 @@ 6128F57E2BA8A3A000D35B08 /* DataStore+TLVTests.swift in Sources */, D224430D29E95D6700274EC7 /* CrashReportReceiverTests.swift in Sources */, 96F69D6C2CBE94A800A6178B /* DatadogCoreTests.swift in Sources */, + 12D14979916A036A2CC36DBE /* RemoteSamplingSnapshotTests.swift in Sources */, + 5CEEC145E560188306A44870 /* RemoteSamplingControllerTests.swift in Sources */, A731B7FB2EE096B5003D1E4F /* SharedContextTests.swift in Sources */, A731B7FC2EE096B5003D1E4F /* ContextSharingTransformerTests.swift in Sources */, 11F55FD62DCBBAD700DE4944 /* DDURLSessionInstrumentationTests+apiTests.m in Sources */, @@ -10334,6 +10390,7 @@ D23039E1298D5236001A1FA3 /* AppState.swift in Sources */, D2F448E22D43A3DC007BB995 /* CompletionHandler.swift in Sources */, D2DE63532A30A7CA00441A54 /* CoreRegistry.swift in Sources */, + 3CBF72DC4C4F98B6205D5576 /* RemoteSampling.swift in Sources */, D2D748402DC24F1100C61353 /* Crash.swift in Sources */, E2AA55EA2C32C76A002FEF28 /* WatchKitExtensions.swift in Sources */, D2EBEE2829BA160F00B15732 /* W3CHTTPHeadersWriter.swift in Sources */, @@ -10463,6 +10520,7 @@ 1124D5362EA6D4390002E053 /* RUMAppLaunchManager.swift in Sources */, 1124D5372EA6D4390002E053 /* RUMFeatureOperationManager.swift in Sources */, D224430529E9588500274EC7 /* TelemetryReceiver.swift in Sources */, + 02A7E6777D3C038F8CF2AC39 /* RemoteSamplingReceiver.swift in Sources */, D23F8E7029DDCD28001CFAE8 /* URLSessionRUMResourcesHandler.swift in Sources */, 965497062D761FCB006428EE /* SwiftUIViewNameExtractor.swift in Sources */, 11F55FDA2DCE183500DE4944 /* RUMDataModels+objc.swift in Sources */, @@ -10506,6 +10564,7 @@ 11030D762D96EC5C00732D5F /* ViewHitchesMetric.swift in Sources */, D2D748242DC0FF7E00C61353 /* FatalErrorContextNotifier.swift in Sources */, D23F8E8229DDCD28001CFAE8 /* RUMSessionScope.swift in Sources */, + 32989812C6103BA00321C225 /* RUMDrawnConfiguration.swift in Sources */, A7E6EA812D3146AD00997201 /* AnonymousIdentifierManager.swift in Sources */, D23F8E8329DDCD28001CFAE8 /* RUMUser.swift in Sources */, D23F8E8429DDCD28001CFAE8 /* UIKitRUMUserActionsPredicate.swift in Sources */, @@ -10538,6 +10597,7 @@ D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, 61C4534B2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */, + A36A655187BCCE5680EC5984 /* RUMDrawnConfigurationTests.swift in Sources */, 0904F9F52EE1DA6800ED9A22 /* UIKitExtensionsTests.swift in Sources */, 3C4CF9992C47CC92006DE1C0 /* MemoryWarningMonitorTests.swift in Sources */, D23F8EA329DDCD38001CFAE8 /* RUMUserActionScopeTests.swift in Sources */, @@ -10570,6 +10630,7 @@ 61181CDD2BF35BC000632A7A /* FatalErrorContextNotifierTests.swift in Sources */, 61C713BD2A3C95AD00FA735A /* RUMInstrumentationTests.swift in Sources */, D23F8EB329DDCD38001CFAE8 /* ErrorMessageReceiverTests.swift in Sources */, + 224D398714B6DD75DD8044E9 /* RemoteSamplingReceiverTests.swift in Sources */, 61C713C12A3C9DAD00FA735A /* RequestBuilderTests.swift in Sources */, D23F8EB429DDCD38001CFAE8 /* RUMApplicationScopeTests.swift in Sources */, 6105C5152D0C584F00C4C5EE /* INVMetricTests.swift in Sources */, @@ -10956,6 +11017,7 @@ 1124D5382EA6D4390002E053 /* RUMAppLaunchManager.swift in Sources */, 1124D5392EA6D4390002E053 /* RUMFeatureOperationManager.swift in Sources */, D224430429E9588100274EC7 /* TelemetryReceiver.swift in Sources */, + 8BB6657E07CFDC5C252B17E1 /* RemoteSamplingReceiver.swift in Sources */, D29A9F5729DD85BB005C54A4 /* URLSessionRUMResourcesHandler.swift in Sources */, 965497052D761FCB006428EE /* SwiftUIViewNameExtractor.swift in Sources */, 11F55FD92DCE183500DE4944 /* RUMDataModels+objc.swift in Sources */, @@ -10999,6 +11061,7 @@ 11030D772D96EC5C00732D5F /* ViewHitchesMetric.swift in Sources */, D2D748232DC0FF7E00C61353 /* FatalErrorContextNotifier.swift in Sources */, D29A9F5C29DD85BB005C54A4 /* RUMSessionScope.swift in Sources */, + 20EDC40A8B14129FB3E4928B /* RUMDrawnConfiguration.swift in Sources */, A7E6EA822D3146AD00997201 /* AnonymousIdentifierManager.swift in Sources */, D29A9F6629DD85BB005C54A4 /* RUMUser.swift in Sources */, D29A9F8229DD85BB005C54A4 /* UIKitRUMUserActionsPredicate.swift in Sources */, @@ -11031,6 +11094,7 @@ D29A9FA629DDB483005C54A4 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, 61C4534A2C0A0BBF00CC4C17 /* TelemetryInterceptorTests.swift in Sources */, D29A9FBD29DDB483005C54A4 /* RUMSessionScopeTests.swift in Sources */, + DE687A414FCC3A4D328ABC7E /* RUMDrawnConfigurationTests.swift in Sources */, 0904F9F62EE1DA6800ED9A22 /* UIKitExtensionsTests.swift in Sources */, 3C4CF9982C47CC91006DE1C0 /* MemoryWarningMonitorTests.swift in Sources */, D29A9FAB29DDB483005C54A4 /* RUMUserActionScopeTests.swift in Sources */, @@ -11062,6 +11126,7 @@ 61181CDC2BF35BC000632A7A /* FatalErrorContextNotifierTests.swift in Sources */, 61C713BC2A3C95AD00FA735A /* RUMInstrumentationTests.swift in Sources */, D29A9FBB29DDB483005C54A4 /* ErrorMessageReceiverTests.swift in Sources */, + B1A879D1C2D68AD9AA4BB1AE /* RemoteSamplingReceiverTests.swift in Sources */, 61C713C02A3C9DAD00FA735A /* RequestBuilderTests.swift in Sources */, D29A9F9F29DDB483005C54A4 /* RUMApplicationScopeTests.swift in Sources */, 6105C5142D0C584F00C4C5EE /* INVMetricTests.swift in Sources */, @@ -11256,6 +11321,8 @@ 3C0D5DE52A543E3500446CF9 /* EventGenerator.swift in Sources */, D2EFA869286DA85700F1FAA6 /* DatadogContextProvider.swift in Sources */, D2B3F04E282A85FD00C2B5EE /* DatadogCore.swift in Sources */, + FF1587B3CF5C9AC1CA800DAF /* RemoteSamplingSnapshot.swift in Sources */, + F7143B3DEDFBE78C328A89B9 /* RemoteSamplingController.swift in Sources */, 6128F5752BA3280300D35B08 /* DataStoreFileReader.swift in Sources */, D2303A0B298D5412001A1FA3 /* AsyncWriter.swift in Sources */, D224430729E95C2E00274EC7 /* MessageBus.swift in Sources */, @@ -11361,6 +11428,8 @@ D2CB6F1327C520D400A62B57 /* DDConfigurationTests.swift in Sources */, D2CB6F1727C520D400A62B57 /* ObjcExceptionHandlerTests.swift in Sources */, 96F69D6D2CBE94A900A6178B /* DatadogCoreTests.swift in Sources */, + 282EF63D069B9056BD53AF91 /* RemoteSamplingSnapshotTests.swift in Sources */, + 0E81AE3C56D66A15CCF3B697 /* RemoteSamplingControllerTests.swift in Sources */, D28F836B29C9E7A300EF8EA2 /* TracingURLSessionHandlerTests.swift in Sources */, D2CB6F1827C520D400A62B57 /* DatadogTestsObserver.swift in Sources */, D2CB6F1927C520D400A62B57 /* RequestBuilderTests.swift in Sources */, @@ -11561,6 +11630,7 @@ D28FB6972DB7D3F000CD76D0 /* RUMDataModels.swift in Sources */, D2F448E12D43A3DC007BB995 /* CompletionHandler.swift in Sources */, D2DE63542A30A7CA00441A54 /* CoreRegistry.swift in Sources */, + 70B102EEA2DD2A8186D31B62 /* RemoteSampling.swift in Sources */, E2AA55EC2C32C78B002FEF28 /* WatchKitExtensions.swift in Sources */, D2EBEE3629BA161100B15732 /* W3CHTTPHeadersWriter.swift in Sources */, D2DA236D298D57AA00C6C7E6 /* DeviceInfo.swift in Sources */, diff --git a/DatadogCore/Sources/Core/DatadogCore.swift b/DatadogCore/Sources/Core/DatadogCore.swift index 1ef1ef7426..5ffd08ca30 100644 --- a/DatadogCore/Sources/Core/DatadogCore.swift +++ b/DatadogCore/Sources/Core/DatadogCore.swift @@ -55,6 +55,35 @@ internal final class DatadogCore { /// The message-bus instance. let bus = MessageBus() + /// Keeps the remote sampling configuration in step with the console. + /// + /// Built on demand because it captures `self` and because an app that never asked for remote + /// configuration must not pay for the store's directory. `lazy` cannot do that job here: it is + /// reached both from the context queue (a published source) and from the thread that enables + /// RUM (the priming read), and Swift's lazy initialisation is not atomic. + @ReadWriteLock + private var _remoteSamplingController: RemoteSamplingController? + + var remoteSamplingController: RemoteSamplingController { + if let existing = _remoteSamplingController { + return existing + } + let created = RemoteSamplingController( + httpClient: httpClient, + contextProvider: contextProvider, + store: try? RemoteSamplingSnapshotStore(coreDirectory: directory), + telemetry: telemetry, + publishRates: { [weak self] rates in + self?.contextProvider.write { $0.set(additionalContext: rates) } + }, + notifyImmediateChange: { [weak self] in + self?.send(message: .payload(RemoteSamplingChangedMessage()), else: {}) + } + ) + _remoteSamplingController = created + return created + } + /// Registry for Features. @ReadWriteLock private(set) var stores: [String: (storage: FeatureStorage, upload: FeatureUpload)] = [:] @@ -389,7 +418,19 @@ extension DatadogCore: DatadogCoreProtocol { } func set(context: @escaping () -> Context?) where Context: AdditionalContext { - contextProvider.write { $0.set(additionalContext: context()) } + contextProvider.write { [weak self] coreContext in + // Evaluated on the context queue, never on the caller's thread. The closures features + // pass in here read their own scopes, and those scopes are mutated from this queue — + // so calling it anywhere else turns an ordinary read into a data race. + let value = context() + coreContext.set(additionalContext: value) + // RUM publishes its configuration source at SDK init and at every session creation; + // each publication is one opportunity for the controller to ask the console. No other + // context type is a trigger, so rates the controller itself publishes cannot loop. + if let source = value as? RemoteSamplingSource { + self?.remoteSamplingController.onSourcePublished(source) + } + } } func send(message: FeatureMessage, else fallback: @escaping () -> Void) { @@ -634,3 +675,24 @@ internal let registerObjcExceptionHandlerOnce: () -> Void = { ObjcException.rethrow = __dd_private_ObjcExceptionHandler.rethrow return {} }() + +extension DatadogCore: RemoteSamplingReader { + /// Reads the stored configuration into effect before RUM can draw its first session under it. + /// + /// The address depends on RUM's own endpoint configuration, which the core does not otherwise + /// know, so the caller builds it from the context handed to the closure. + @discardableResult + func primeRemoteSampling(source: (DatadogContext) -> RemoteSamplingSource?) -> RemoteSamplingRates? { + let context = contextProvider.read() + guard let source = source(context) else { + return nil + } + return remoteSamplingController.prime(source: source, context: context) + } + + var remoteSamplingRates: RemoteSamplingRates? { + // Only the controller that was actually built can have rates; asking for one here would + // create it for an app that never opted in. + _remoteSamplingController?.currentRates + } +} diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift new file mode 100644 index 0000000000..d701a5c97d --- /dev/null +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingController.swift @@ -0,0 +1,259 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +/// Keeps the stored sampling configuration in step with what the console says. +/// +/// The fetch model is session-driven: RUM publishes `RemoteSamplingSource` when the SDK starts and +/// again every time a session begins, and each publication is one opportunity to fetch. There is no +/// polling — the console's `ttl` is intentionally not honoured on iOS. A request in flight +/// deduplicates triggers; a failed request is retried after 5s and then 60s (±20% jitter), after +/// which the controller waits for the next trigger. Nothing here can hold up the SDK or interrupt +/// collection: a request that fails, times out or comes back unreadable leaves the stored values +/// exactly as they were. Wiping them on a bad minute would swing a whole fleet back to the rates it +/// was built with, which is the opposite of what someone who turned a knob deliberately wants. +internal final class RemoteSamplingController { + /// The delays before the first and second retry of a failed fetch. + private static let retryDelays: [TimeInterval] = [5, 60] + + /// The queue every piece of state below lives on. + private let queue = DispatchQueue(label: "com.datadoghq.remote-sampling", target: .global(qos: .utility)) + + private let httpClient: HTTPClient + private let contextProvider: DatadogContextProvider + private let store: RemoteSamplingSnapshotStore? + private let telemetry: Telemetry + + /// Hands the rates to the core, which publishes them as additional context for all features. + private let publishRates: (RemoteSamplingRates) -> Void + /// Tells the core the console asked for an immediate change; the core notifies RUM on the bus. + private let notifyImmediateChange: () -> Void + /// Schedules a retry; injectable so tests do not wait. + private let schedule: (TimeInterval, @escaping () -> Void) -> Void + /// Applies ±20% jitter to a retry delay; injectable so tests are deterministic. + private let jitter: (TimeInterval) -> TimeInterval + + /// The snapshot currently in effect, mirrored to disk after every change. + private var snapshot: RemoteSamplingSnapshot = .empty + /// The rates the snapshot resolves to, readable without waiting on `queue`. + /// + /// The context is how these reach every other feature, but a session draw cannot wait for a + /// queue hop — it happens now, and the answer has to be the one already on disk. + @ReadWriteLock + private(set) var currentRates: RemoteSamplingRates? + /// The storage key of the running configuration; `nil` until the first source is seen. + private var storageKey: String? + /// Whether the stored snapshot was already loaded for `storageKey`. + private var didLoadStoredSnapshot = false + /// Whether a fetch or a pending retry is in flight, deduplicating triggers. + private var inFlight = false + /// How many retries the current fetch chain already used. + private var retryAttempt = 0 + /// The last source a trigger arrived with; retries aim at the same address. + private var lastSource: RemoteSamplingSource? + + init( + httpClient: HTTPClient, + contextProvider: DatadogContextProvider, + store: RemoteSamplingSnapshotStore?, + telemetry: Telemetry, + publishRates: @escaping (RemoteSamplingRates) -> Void, + notifyImmediateChange: @escaping () -> Void, + schedule: ((TimeInterval, @escaping () -> Void) -> Void)? = nil, + jitter: ((TimeInterval) -> TimeInterval)? = nil + ) { + self.httpClient = httpClient + self.contextProvider = contextProvider + self.store = store + self.telemetry = telemetry + self.publishRates = publishRates + self.notifyImmediateChange = notifyImmediateChange + self.schedule = schedule ?? { delay, work in + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay, execute: work) + } + self.jitter = jitter ?? { delay in + delay * Double.random(in: 0.8...1.2) + } + } + + /// Handles a publication of `RemoteSamplingSource`: one opportunity to fetch. + /// + /// Called by the core every time a feature sets the source, which RUM does at SDK init and at + /// every session creation. The first publication for a storage key also loads the snapshot + /// persisted by a previous launch and publishes it, so the values the console last provided + /// apply again before the network answers. + func onSourcePublished(_ source: RemoteSamplingSource) { + queue.async { + self.contextProvider.read { context in + self.queue.async { + self.handleTrigger(source: source, context: context) + } + } + } + } + + // MARK: - Private; every method below runs on `queue` + + /// Loads what a previous launch stored for this source, exactly once per storage key. + /// + /// Runs on `queue` like every other piece of state here — reached either from a trigger or, + /// before any session exists, from `prime(source:context:)`. + private func loadStoredSnapshotIfNeeded(source: RemoteSamplingSource, context: DatadogContext) { + lastSource = source + let key = RemoteSamplingSnapshotStore.key(source: source, context: context) + if key != storageKey { + storageKey = key + didLoadStoredSnapshot = false + snapshot = .empty + currentRates = nil + } + guard !didLoadStoredSnapshot else { + return + } + didLoadStoredSnapshot = true + if let stored = store?.load(forKey: key), stored.version > 0 { + snapshot = stored + currentRates = stored.rates + publishRates(stored.rates) + } + } + + /// Reads the stored configuration into effect before anything can be drawn under it. + /// + /// Blocking is the point: the caller is about to draw a session, and the whole reason the + /// snapshot is on disk is so that draw uses it rather than the values the app was built with. + /// It only touches storage — no request is made here. + func prime(source: RemoteSamplingSource, context: DatadogContext) -> RemoteSamplingRates? { + queue.sync { + loadStoredSnapshotIfNeeded(source: source, context: context) + } + return currentRates + } + + private func handleTrigger(source: RemoteSamplingSource, context: DatadogContext) { + loadStoredSnapshotIfNeeded(source: source, context: context) + + guard !inFlight else { + return + } + inFlight = true + retryAttempt = 0 + fetch(source: source) + } + + private func fetch(source: RemoteSamplingSource) { + var components = URLComponents(url: source.configurationURL, resolvingAgainstBaseURL: false) + if snapshot.version > 0 { + // Telling the server which version this app runs is what lets the console answer + // "has my change reached everyone yet". + var items = components?.queryItems ?? [] + items.append(URLQueryItem(name: "applied_version", value: String(snapshot.version))) + components?.queryItems = items + } + + guard let url = components?.url else { + telemetry.error("Remote sampling: could not build the configuration URL from \(source.configurationURL)") + inFlight = false + return + } + + var request = URLRequest(url: url) + if let etag = snapshot.etag { + request.setValue(etag, forHTTPHeaderField: "If-None-Match") + } + + httpClient.fetch(request: request) { [weak self] result in + self?.queue.async { + self?.handleResponse(result) + } + } + } + + private func handleResponse(_ result: Result<(response: HTTPURLResponse, body: Data), Error>) { + switch result { + case .success((let response, let body)) where response.statusCode == 200: + do { + let parsed = try RemoteSamplingResponse.parse(body: body, etag: remoteSamplingETag(for: body)) + activate(parsed) + } catch let error as RemoteSamplingUnsupportedSchemaError { + // The server answered; this SDK simply cannot use the answer until it is updated. + // Asking again would fetch the same refusal, so the ask is over. + telemetry.error( + """ + Remote sampling: ignoring a configuration written to schema version \ + \(error.received.map(String.init) ?? "none"); this SDK reads version \ + \(RemoteSamplingResponse.supportedSchemaVersion). Update the SDK to take the \ + console's settings again. + """ + ) + inFlight = false + } catch { + telemetry.debug("Remote sampling: rejecting an invalid configuration response, keeping the previous one") + scheduleRetry() + } + case .success((let response, _)) where response.statusCode == 304: + inFlight = false + case .success((let response, _)): + telemetry.debug("Remote sampling: configuration request answered \(response.statusCode), keeping the previous values") + scheduleRetry() + case .failure(let error): + telemetry.debug("Remote sampling: configuration request failed (\(error.localizedDescription)), keeping the previous values") + scheduleRetry() + } + } + + /// Activates a validated snapshot all-or-nothing: persist it, publish its rates, and when the + /// console asked for an immediate change that really changes what this client draws with, + /// let RUM know so it ends the running session. + private func activate(_ parsed: RemoteSamplingResponse) { + // A body older than what is already in force is a stale copy — an edge cache or a proxy + // answering 200 with something it held on to. Versions only ever climb: a rollback in the + // console republishes the older content under a new number, and pruning removes the oldest + // rows, so the newest version never goes down. Applying one that did would put this client + // back on settings the console has already replaced, and it would keep reporting that older + // number, so the rollout view would read as the change losing ground. + guard parsed.snapshot.version >= snapshot.version else { + telemetry.debug("Remote sampling: ignoring a configuration older than the one in force") + inFlight = false + return + } + + let previousSessionSampleRate = snapshot.rates.sessionSampleRate + snapshot = parsed.snapshot + if let storageKey = storageKey { + store?.save(snapshot, forKey: storageKey) + } + currentRates = snapshot.rates + publishRates(snapshot.rates) + inFlight = false + + let drawChanged = previousSessionSampleRate != snapshot.rates.sessionSampleRate + if parsed.activation == .immediate && drawChanged { + notifyImmediateChange() + } + } + + private func scheduleRetry() { + guard retryAttempt < Self.retryDelays.count else { + // Out of retries: keep the stored values and wait for the next session trigger. + inFlight = false + return + } + let delay = jitter(Self.retryDelays[retryAttempt]) + retryAttempt += 1 + schedule(delay) { [weak self] in + self?.queue.async { + // The retry only fires when the chain is still expected to be in flight. + guard let self = self, self.inFlight, let source = self.lastSource else { + return + } + self.fetch(source: source) + } + } + } +} diff --git a/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift new file mode 100644 index 0000000000..a9f99b9a5a --- /dev/null +++ b/DatadogCore/Sources/Core/RemoteSampling/RemoteSamplingSnapshot.swift @@ -0,0 +1,310 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import CommonCrypto +import Foundation +import DatadogInternal + +/// The last configuration the console provided, as the client keeps it. +/// +/// A snapshot is activated all-or-nothing: a response that does not parse or that carries a value +/// of the wrong shape is rejected whole and the previous snapshot stays in effect. +internal struct RemoteSamplingSnapshot: Equatable, Codable { + /// The console's version of this configuration. `0` means the console never provided one. + var version: Int64 + /// The validator the next conditional request is sent with; `nil` until the first `200`. + var etag: String? + /// Whether the console wants remote configuration applied at all. + /// + /// `false` is the kill switch: every knob is cleared (so the app falls back to the values it + /// was initialised with) but the version is kept, so the client still reports what it runs. + var enabled: Bool + var sessionSampleRate: SampleRate? + /// The console's custom values, as the raw JSON object they were delivered in. + var custom: String? + + /// The snapshot before the console answers for the first time. + static let empty = RemoteSamplingSnapshot( + version: 0, + etag: nil, + enabled: false, + sessionSampleRate: nil, + custom: nil + ) + + /// The rates RUM draws each new session with. + var rates: RemoteSamplingRates { + guard enabled else { + // Kill switch: no knob and no custom values, only the version survives. + return RemoteSamplingRates( + sessionSampleRate: nil, + version: version + ) + } + return RemoteSamplingRates( + sessionSampleRate: sessionSampleRate, + version: version, + custom: custom + ) + } +} + +/// The outcome of reading a `200` response body against the configuration contract. +internal struct RemoteSamplingResponse: Equatable { + /// How the console wants the configuration to take effect. + enum Activation: String, Equatable { + /// New sessions draw with the new values; the running session is untouched. + case nextSession = "next_session" + /// The running session ends so the next one starts under the new values. + case immediate = "immediate" + } + + let snapshot: RemoteSamplingSnapshot + let activation: Activation +} + +/// An unparseable or invalid configuration response. Carries no details on purpose: +/// the handling is the same whatever is wrong — keep the old snapshot. +internal struct RemoteSamplingResponseError: Error {} + +/// A configuration written to a contract this SDK does not read. +/// +/// Kept apart from `RemoteSamplingResponseError` because the two deserve opposite answers: an +/// unreadable body is worth asking again for, a schema we do not know is not — the next answer +/// would be the same refusal. +internal struct RemoteSamplingUnsupportedSchemaError: Error { + let received: Int? +} + +extension RemoteSamplingResponse { + /// Reads a configuration response body. + /// + /// The contract is a flat JSON object. Keys the SDK does not know are ignored, so an older SDK + /// can talk to a newer console. A known key carrying the wrong type — or a rate outside + /// 0...100 — rejects the whole response: half-activated configuration is worse than none. + /// + /// - Parameters: + /// - body: The response body. + /// - etag: The validator computed from the body by the caller. + static func parse(body: Data, etag: String) throws -> RemoteSamplingResponse { + guard let json = try? JSONSerialization.jsonObject(with: body), + let root = json as? [String: Any] else { + throw RemoteSamplingResponseError() + } + + // Checked before anything else is read out of the body. The server states the shape it + // wrote, and a reader that guesses instead of checking is exactly what this field exists + // to prevent — which is why it has to be honoured by the first SDK that ships, not by a + // later one: only code already on the device can refuse. + try checkSchemaVersion(root) + + let version = try readVersion(root) + let enabled = try readBoolean(root, key: Contract.enabled, default: false) + let activation = try readActivation(root) + + guard enabled else { + // Kill switch: clear every knob, keep the version. `rum` and `custom` are not read — + // the console sends `rum: {}` here, and whatever it carries must not apply. + return RemoteSamplingResponse( + snapshot: RemoteSamplingSnapshot( + version: version, + etag: etag, + enabled: false, + sessionSampleRate: nil, + custom: nil + ), + activation: activation + ) + } + + let rum = try readDictionary(root, key: Contract.rum) ?? [:] + let custom = try readCustom(root) + + return RemoteSamplingResponse( + snapshot: RemoteSamplingSnapshot( + version: version, + etag: etag, + enabled: true, + sessionSampleRate: try readRate(rum, key: Contract.sessionSampleRate), + custom: custom + ), + activation: activation + ) + } + + // MARK: - Whitelisted readers + + /// The contract this SDK reads. Not the SDK version and not the settings version: it names the + /// SHAPE of the body, and the server bumps it only when a body would be misread by a reader + /// written against the previous shape. + static let supportedSchemaVersion = 1 + + private static func checkSchemaVersion(_ root: [String: Any]) throws { + // A body carrying no stamp at all is, by construction, the shape that existed before the + // stamp did — which is the shape this reader was written against. Refusing it would switch + // remote configuration silently off for every client on this platform whenever it is + // pointed at a server that merely predates the field, and nothing would say so. Only a + // stamp we can see and do not recognise is a reason to refuse. + // A key that is absent, or present as an explicit null, both say the same thing: nothing + // was stamped. The other SDKs read them the same way. + guard let raw = root[Contract.schemaVersion], !(raw is NSNull) else { + return + } + guard let number = raw as? NSNumber, !isBoolean(raw), number.intValue == supportedSchemaVersion else { + let received = (raw as? NSNumber).map { $0.intValue } + throw RemoteSamplingUnsupportedSchemaError(received: received) + } + } + + private enum Contract { + static let schemaVersion = "schema_version" + static let version = "version" + static let enabled = "enabled" + static let activation = "activation" + static let rum = "rum" + static let custom = "custom" + static let sessionSampleRate = "sessionSampleRate" + } + + private static func readVersion(_ root: [String: Any]) throws -> Int64 { + guard let raw = root[Contract.version] else { + throw RemoteSamplingResponseError() // a configuration without a version cannot be reported back + } + guard let version = raw as? NSNumber, !isBoolean(raw), version.int64Value >= 0 else { + throw RemoteSamplingResponseError() + } + return version.int64Value + } + + private static func readBoolean(_ root: [String: Any], key: String, default defaultValue: Bool) throws -> Bool { + guard let raw = root[key] else { + return defaultValue + } + guard let number = raw as? NSNumber, isBoolean(raw) else { + throw RemoteSamplingResponseError() + } + return number.boolValue + } + + private static func readActivation(_ root: [String: Any]) throws -> Activation { + guard let raw = root[Contract.activation] else { + return .nextSession + } + guard let string = raw as? String, let activation = Activation(rawValue: string) else { + throw RemoteSamplingResponseError() + } + return activation + } + + private static func readDictionary(_ root: [String: Any], key: String) throws -> [String: Any]? { + guard let raw = root[key] else { + return nil + } + guard let dictionary = raw as? [String: Any] else { + throw RemoteSamplingResponseError() + } + return dictionary + } + + /// A rate the response did not send stays absent, so the value passed to init keeps applying. + /// A rate outside 0...100 rejects the whole response rather than being clamped: a rate we + /// cannot trust is not a rate to sample a customer's traffic with. + private static func readRate(_ rum: [String: Any], key: String) throws -> SampleRate? { + guard let raw = rum[key] else { + return nil + } + guard let number = raw as? NSNumber, !isBoolean(raw) else { + throw RemoteSamplingResponseError() + } + let rate = number.doubleValue + guard rate >= 0, rate <= 100 else { + throw RemoteSamplingResponseError() + } + return SampleRate(rate) + } + + /// Custom values are delivered to the host application as the raw JSON object they arrived in. + private static func readCustom(_ root: [String: Any]) throws -> String? { + guard let dictionary = try readDictionary(root, key: Contract.custom) else { + return nil + } + guard JSONSerialization.isValidJSONObject(dictionary), + let data = try? JSONSerialization.data(withJSONObject: dictionary, options: [.sortedKeys]), + let string = String(data: data, encoding: .utf8) else { + throw RemoteSamplingResponseError() + } + return string + } + + /// `JSONSerialization` bridges JSON booleans to `NSNumber`, so a type check needs care. + private static func isBoolean(_ value: Any) -> Bool { + CFGetTypeID(value as CFTypeRef) == CFBooleanGetTypeID() + } +} + +/// Computes the validator of a configuration response body: the first 16 hex characters of its +/// SHA-256, quoted, as the contract fixes it. Sent back as `If-None-Match`; the server answers +/// `304` when the body would be identical. +internal func remoteSamplingETag(for body: Data) -> String { + var digest: [UInt8] = Array(repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + _ = body.withUnsafeBytes { CC_SHA256($0.baseAddress, UInt32(body.count), &digest) } + let hex = digest.map { String(format: "%02x", $0) }.joined() + return "\"\(hex.prefix(16))\"" +} + +/// Persists the last good snapshot on disk, so the first sessions after a cold start draw with +/// the values the console provided on a previous launch instead of the ones the app was built with. +/// +/// The storage key embeds everything that makes a configuration entry invalid for another +/// configuration: a cache-format version (bumped when the layout changes), the endpoint host, the +/// application (service and bundle id), the environment and the application version. The SDK +/// version is deliberately not part of it — an SDK update must not throw away the console's +/// answer. Entries another key cannot see are simply never read. +internal struct RemoteSamplingSnapshotStore { + /// Bumped when the persisted layout changes, so old entries are left behind instead of misread. + private static let cacheFormatVersion = "cfv1" + /// Subdirectory of the core directory the snapshots live in. + private static let subdirectory = "remote-config" + + private let directory: Directory + + init(coreDirectory: CoreDirectory) throws { + self.directory = try coreDirectory.coreDirectory.createSubdirectory(path: Self.subdirectory) + } + + /// Builds the storage key for the given source and application context. + static func key(source: RemoteSamplingSource, context: DatadogContext) -> String { + let material = [ + cacheFormatVersion, + source.configurationURL.host ?? "", + source.configurationURL.port.map { String($0) } ?? "", + context.service, + context.applicationBundleIdentifier, + context.env, + context.version + ].joined(separator: "|") + return sha256(material) + } + + func load(forKey key: String) -> RemoteSamplingSnapshot? { + let url = fileURL(forKey: key) + guard let data = try? Data(contentsOf: url) else { + return nil + } + return try? JSONDecoder().decode(RemoteSamplingSnapshot.self, from: data) + } + + func save(_ snapshot: RemoteSamplingSnapshot, forKey key: String) { + guard let data = try? JSONEncoder().encode(snapshot) else { + return + } + try? data.write(to: fileURL(forKey: key), options: .atomic) + } + + private func fileURL(forKey key: String) -> URL { + directory.url.appendingPathComponent(key) + } +} diff --git a/DatadogCore/Sources/Core/Upload/HTTPClient.swift b/DatadogCore/Sources/Core/Upload/HTTPClient.swift index f881608215..d0d26cb9eb 100644 --- a/DatadogCore/Sources/Core/Upload/HTTPClient.swift +++ b/DatadogCore/Sources/Core/Upload/HTTPClient.swift @@ -14,6 +14,17 @@ internal protocol HTTPClient { /// - delegate: The task-specific delegate. /// - completion: A closure that receives a Result containing either an HTTPURLResponse or an Error. func send(request: URLRequest, delegate: URLSessionTaskDelegate?, completion: @escaping (Result) -> Void) + + /// Sends the provided request and hands back the response together with its body. + /// + /// Uploading only needs to know how the backend replied, so `send(request:)` discards the body. + /// Asking the backend for something — the sampling configuration the console sets — needs what + /// came back, and must go through the same client so it honours the proxy the customer + /// configured rather than quietly bypassing it. + /// - Parameters: + /// - request: The request to be sent. + /// - completion: A closure that receives a Result containing either the response and its body, or an Error. + func fetch(request: URLRequest, completion: @escaping (Result<(response: HTTPURLResponse, body: Data), Error>) -> Void) } extension HTTPClient { diff --git a/DatadogCore/Sources/Core/Upload/URLSessionClient.swift b/DatadogCore/Sources/Core/Upload/URLSessionClient.swift index 924ecb8535..3f7af60127 100644 --- a/DatadogCore/Sources/Core/Upload/URLSessionClient.swift +++ b/DatadogCore/Sources/Core/Upload/URLSessionClient.swift @@ -44,6 +44,21 @@ internal class URLSessionClient: HTTPClient { } task.resume() } + + func fetch(request: URLRequest, completion: @escaping (Result<(response: HTTPURLResponse, body: Data), Error>) -> Void) { + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + guard let httpResponse = response as? HTTPURLResponse else { + completion(.failure(URLSessionTransportInconsistencyException())) + return + } + completion(.success((response: httpResponse, body: data ?? Data()))) + } + task.resume() + } } /// An error returned if `URLSession` response state is inconsistent (like no data, no response and no error). diff --git a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift new file mode 100644 index 0000000000..72517436a1 --- /dev/null +++ b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingControllerTests.swift @@ -0,0 +1,412 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import DatadogInternal +import TestUtilities +import XCTest +@testable import DatadogCore + +class RemoteSamplingControllerTests: XCTestCase { + private let source = RemoteSamplingSource( + configurationURL: URL(string: "https://intake.example.com/api/v2/rum/config?client_token=t&sdk=ios")! + ) + + /// A stub client answering each fetch through a programmable handler. + private final class FetchStub: HTTPClient { + private let lock = NSLock() + private var _calls: [URLRequest] = [] + var calls: [URLRequest] { + lock.lock(); defer { lock.unlock() } + return _calls + } + var handler: (URLRequest) -> Result<(response: HTTPURLResponse, body: Data), Error> = { _ in + .failure(NSError.mockAny()) + } + + func send(request: URLRequest, delegate: URLSessionTaskDelegate?, completion: @escaping (Result) -> Void) {} + + func fetch(request: URLRequest, completion: @escaping (Result<(response: HTTPURLResponse, body: Data), Error>) -> Void) { + // Recorded on receipt, and answered off the caller's thread — the same shape as a real + // HTTP client. Answering synchronously would run the whole exchange inside the + // controller's own queue hop, which is precisely when its in-flight guard cannot be + // observed to do anything. + lock.lock() + _calls.append(request) + lock.unlock() + let handler = self.handler + DispatchQueue.global(qos: .utility).async { + completion(handler(request)) + } + } + } + + /// Thread-safe record of everything the controller did. + private final class Recorder { + private let lock = NSLock() + private var _published: [RemoteSamplingRates] = [] + private var _immediateChangeCount = 0 + private var _scheduledDelays: [TimeInterval] = [] + private var _pendingWork: [() -> Void] = [] + + var published: [RemoteSamplingRates] { + lock.lock(); defer { lock.unlock() } + return _published + } + var immediateChangeCount: Int { + lock.lock(); defer { lock.unlock() } + return _immediateChangeCount + } + var scheduledDelays: [TimeInterval] { + lock.lock(); defer { lock.unlock() } + return _scheduledDelays + } + var pendingWork: [() -> Void] { + lock.lock(); defer { lock.unlock() } + return _pendingWork + } + + func publish(_ rates: RemoteSamplingRates) { + lock.lock(); _published.append(rates); lock.unlock() + } + func notifyImmediateChange() { + lock.lock(); _immediateChangeCount += 1; lock.unlock() + } + func schedule(_ delay: TimeInterval, work: @escaping () -> Void) { + lock.lock() + _scheduledDelays.append(delay) + _pendingWork.append(work) + lock.unlock() + } + } + + private struct Harness { + let client = FetchStub() + let recorder = Recorder() + let controller: RemoteSamplingController + + init(store: RemoteSamplingSnapshotStore? = nil, context: DatadogContext = .mockAny()) { + let recorder = self.recorder + controller = RemoteSamplingController( + httpClient: client, + contextProvider: DatadogContextProvider(context: context), + store: store, + telemetry: NOPTelemetry(), + publishRates: { recorder.publish($0) }, + notifyImmediateChange: { recorder.notifyImmediateChange() }, + schedule: { recorder.schedule($0, work: $1) }, + jitter: { $0 } // no jitter in tests + ) + } + } + + /// Waits until `condition` holds, polling briefly; every controller hop is async. + private func eventually( + _ condition: @autoclosure () -> Bool, + timeout: TimeInterval = 5, + file: StaticString = #filePath, + line: UInt = #line + ) { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + XCTAssertTrue(condition(), "condition not met within \(timeout)s", file: file, line: line) + } + + // MARK: - Fetch model + + func testTriggerFetchesConfiguration() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + + eventually(harness.client.calls.count == 1) + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.recorder.published.last?.sessionSampleRate, 20) + XCTAssertEqual(harness.recorder.published.last?.version, 1) + } + + func testInFlightFetchDeduplicatesTriggers() { + let harness = Harness() + let gate = DispatchSemaphore(value: 0) + harness.client.handler = { _ in + gate.wait() + return .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 1) + harness.controller.onSourcePublished(source) + harness.controller.onSourcePublished(source) + // A trigger reaches the guard only after an asynchronous context read, so it must be given + // that hop before the fetch is released — otherwise this would be measuring which of the + // two landed first rather than whether the guard held. + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + gate.signal() + + eventually(harness.recorder.published.count == 1) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + XCTAssertEqual(harness.client.calls.count, 1, "triggers during a fetch must not start another one") + } + + func testAppliedVersionIsSentOnceKnown() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 42, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + // Wait for the exchange to finish, not merely for the request to go out. The version this + // test is about is only known once the answer has been applied, and the same act of + // applying it is what releases the in-flight guard — so triggering again on the strength of + // the request alone races the response and the second trigger is sometimes dropped. + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.client.calls.count, 1) + let firstQuery = URLComponents(url: harness.client.calls[0].url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertNil(firstQuery?.first(where: { $0.name == "applied_version" }), "no version to report on the very first request") + + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 2) + let query = URLComponents(url: harness.client.calls[1].url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first(where: { $0.name == "applied_version" })?.value, "42") + } + + // MARK: - ETag & 304 + + func testETagStoredAndSentAsIfNoneMatch() { + let harness = Harness() + let body = #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)! + harness.client.handler = { _ in .success((.mockResponseWith(statusCode: 200), body)) } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 1) + + harness.client.handler = { _ in .success((.mockResponseWith(statusCode: 304), Data())) } + harness.controller.onSourcePublished(source) + + eventually(harness.client.calls.count == 2) + XCTAssertEqual( + harness.client.calls[1].value(forHTTPHeaderField: "If-None-Match"), + remoteSamplingETag(for: body) + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + XCTAssertEqual(harness.recorder.published.count, 1, "a 304 changes nothing") + } + + // MARK: - Backoff + + func testFailureRetriesAt5sThen60sThenWaitsForNextTrigger() { + let harness = Harness() + harness.client.handler = { _ in .failure(NSError.mockAny()) } + + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 1) + eventually(harness.recorder.scheduledDelays.count == 1) + XCTAssertEqual(harness.recorder.scheduledDelays[0], 5) + + harness.recorder.pendingWork[0]() + eventually(harness.client.calls.count == 2) + eventually(harness.recorder.scheduledDelays.count == 2) + XCTAssertEqual(harness.recorder.scheduledDelays[1], 60) + + harness.recorder.pendingWork[1]() + eventually(harness.client.calls.count == 3) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + XCTAssertEqual(harness.recorder.scheduledDelays.count, 2, "after two retries the controller waits for the next trigger") + + // A new trigger is a fresh opportunity, with its own two retries: + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 4) + } + + func testSuccessResetsBackoff() { + let harness = Harness() + harness.client.handler = { _ in .failure(NSError.mockAny()) } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.scheduledDelays.count == 1) + + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) + } + harness.recorder.pendingWork[0]() + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.recorder.scheduledDelays.count, 1, "a successful retry schedules nothing further") + } + + func testUnsupportedSchemaKeepsOldValuesAndDoesNotRetry() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 1) + + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 99, "version": 2, "enabled": true, "rum": { "sessionSampleRate": 90 } }"#.data(using: .utf8)!)) + } + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 2) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + + // The server answered; this SDK cannot use the answer. Retrying would fetch the same + // refusal, so nothing is scheduled — and nothing of the refused body is published. + XCTAssertEqual(harness.recorder.scheduledDelays.count, 0) + XCTAssertEqual(harness.recorder.published.count, 1) + XCTAssertEqual(harness.recorder.published.last?.sessionSampleRate, 20) + } + + func testUnsupportedSchemaDoesNotWedgeTheController() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 99, "version": 1, "enabled": true, "rum": {} }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 1) + + // A refused body publishes nothing, so there is no state to wait on: give the controller + // the hop it needs to finish refusing, or the next trigger races the in-flight guard being + // released and is sometimes dropped. + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + + // A refusal must still end the fetch: the next trigger has to reach the network. + harness.controller.onSourcePublished(source) + eventually(harness.client.calls.count == 2) + } + + func testInvalidSnapshotKeepsOldValuesAndRetries() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 1) + + // An unreadable answer must not wipe the values in effect: + harness.client.handler = { _ in .success((.mockResponseWith(statusCode: 200), "garbage".data(using: .utf8)!)) } + harness.controller.onSourcePublished(source) + eventually(harness.recorder.scheduledDelays.count == 1) + XCTAssertEqual(harness.recorder.published.count, 1, "an invalid snapshot is rejected whole, the old one stays") + } + + // MARK: - Kill switch + + func testKillSwitchClearsValuesKeepsVersion() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #""" + { "schema_version": 1, "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 }, "custom": { "a": 1 } } + """#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.recorder.published.last?.sessionSampleRate, 20) + XCTAssertEqual(harness.recorder.published.last?.custom, #"{"a":1}"#) + + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 43, "enabled": false, "rum": {} }"#.data(using: .utf8)!)) + } + harness.controller.onSourcePublished(source) + + eventually(harness.recorder.published.count == 2) + let rates = harness.recorder.published.last + XCTAssertNil(rates?.sessionSampleRate) + XCTAssertNil(rates?.custom) + XCTAssertEqual(rates?.version, 43, "the version survives the kill switch") + } + + // MARK: - Immediate activation + + func testImmediateActivationNotifiesOnlyWhenDrawChanges() { + let harness = Harness() + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 1, "enabled": true, "activation": "immediate", "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.recorder.immediateChangeCount, 1, "the draw changed from nothing to 20") + + // Same rates again: no change, no notification. + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 2) + XCTAssertEqual(harness.recorder.immediateChangeCount, 1) + + // A real change under `next_session`: no notification either. + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 2, "enabled": true, "rum": { "sessionSampleRate": 30 } }"#.data(using: .utf8)!)) + } + harness.controller.onSourcePublished(source) + eventually(harness.recorder.published.count == 3) + XCTAssertEqual(harness.recorder.immediateChangeCount, 1) + } + + // MARK: - Persistence + + func testStoredSnapshotAppliesBeforeNetworkAnswers() throws { + CreateTemporaryDirectory() + defer { DeleteTemporaryDirectory() } + + let store = try makeStore() + store.save( + RemoteSamplingSnapshot( + version: 42, + etag: "\"0123456789abcdef\"", + enabled: true, + sessionSampleRate: 20, + custom: nil + ), + forKey: RemoteSamplingSnapshotStore.key(source: source, context: .mockAny()) + ) + let harness = Harness(store: store, context: .mockAny()) + harness.client.handler = { _ in .failure(NSError.mockAny()) } + + harness.controller.onSourcePublished(source) + + eventually(harness.recorder.published.count == 1) + XCTAssertEqual(harness.recorder.published.last?.sessionSampleRate, 20, "the persisted values apply before the fetch answers") + eventually(harness.client.calls.count == 1) + let request = harness.client.calls[0] + XCTAssertEqual(request.value(forHTTPHeaderField: "If-None-Match"), "\"0123456789abcdef\"") + let query = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first(where: { $0.name == "applied_version" })?.value, "42") + } + + func testSnapshotIsPersistedOnSuccess() throws { + CreateTemporaryDirectory() + defer { DeleteTemporaryDirectory() } + + let store = try makeStore() + let harness = Harness(store: store, context: .mockAny()) + harness.client.handler = { _ in + .success((.mockResponseWith(statusCode: 200), #"{ "schema_version": 1, "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#.data(using: .utf8)!)) + } + + harness.controller.onSourcePublished(source) + + eventually(harness.recorder.published.count == 1) + let stored = store.load(forKey: RemoteSamplingSnapshotStore.key(source: source, context: .mockAny())) + XCTAssertEqual(stored?.version, 42) + XCTAssertEqual(stored?.sessionSampleRate, 20) + XCTAssertNotNil(stored?.etag) + } + + // MARK: - Helpers + + private func makeStore() throws -> RemoteSamplingSnapshotStore { + let directory = try Directory(url: temporaryDirectory).createSubdirectory(path: "remote-sampling-tests-\(UUID().uuidString)") + return try RemoteSamplingSnapshotStore( + coreDirectory: CoreDirectory(osDirectory: directory, coreDirectory: directory) + ) + } +} diff --git a/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift new file mode 100644 index 0000000000..4d1e3b86fc --- /dev/null +++ b/DatadogCore/Tests/Datadog/DatadogCore/RemoteSampling/RemoteSamplingSnapshotTests.swift @@ -0,0 +1,246 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import DatadogInternal +import TestUtilities +import XCTest +@testable import DatadogCore + +class RemoteSamplingSnapshotTests: XCTestCase { + // MARK: - Parsing & validation + + func testParsesFullResponse() throws { + let body = """ + { + "schema_version": 1, + "version": 42, "ttl": 600, "enabled": true, + "activation": "next_session", + "refresh_on_foreground": false, + "rum": { + "sessionSampleRate": 20 + }, + "custom": { "viplist": ["u-1"] } + } + """.data(using: .utf8)! + + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + + XCTAssertEqual(response.activation, .nextSession) + XCTAssertEqual(response.snapshot.version, 42) + XCTAssertTrue(response.snapshot.enabled) + XCTAssertEqual(response.snapshot.sessionSampleRate, 20) + XCTAssertEqual(response.snapshot.custom, #"{"viplist":["u-1"]}"#) + } + + func testAbsentKnobsStayAbsentNotZero() throws { + let body = #"{ "schema_version": 1, "version": 7, "enabled": true, "rum": {} }"#.data(using: .utf8)! + + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + + XCTAssertNil(response.snapshot.sessionSampleRate) + XCTAssertNil(response.snapshot.custom) + } + + func testIgnoresUnknownKeys() throws { + let body = #""" + { "schema_version": 1, "version": 7, "enabled": true, "future-field": { "anything": 1 }, "rum": { "sessionSampleRate": 30, "futureKnob": 9 } } + """#.data(using: .utf8)! + + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + + XCTAssertEqual(response.snapshot.sessionSampleRate, 30) + } + + func testRejectsWholeSnapshotOnInvalidType() { + let bodies = [ + #"not json"#, + #"["array"]"#, + #"{ "schema_version": 1, "version": "42", "enabled": true }"#, // version of wrong type + #"{ "schema_version": 1, "enabled": true, "rum": {} }"#, // no version + #"{ "schema_version": 1, "version": 1, "enabled": "yes" }"#, // enabled of wrong type + #"{ "schema_version": 1, "version": 1, "enabled": true, "activation": "sometimes" }"#, // unknown activation + #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": "nope" }"#, // rum of wrong type + #"{ "schema_version": 1, "version": 1, "enabled": true, "custom": "nope" }"#, // custom of wrong type + #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": "20" } }"#, // rate of wrong type + ] + + for string in bodies { + let body = string.data(using: .utf8)! + XCTAssertThrowsError(try RemoteSamplingResponse.parse(body: body, etag: .mockAny()), "should reject: \(string)") + } + } + + func testRejectsWholeSnapshotOnOutOfRangeRate() { + for rate in [-1, 100.5] { + let body = #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": \#(rate) } }"#.data(using: .utf8)! + XCTAssertThrowsError(try RemoteSamplingResponse.parse(body: body, etag: .mockAny())) + } + } + + func testAcceptsBoundaryRates() throws { + for rate in [0, 100] { + let body = #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": \#(rate) } }"#.data(using: .utf8)! + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + XCTAssertEqual(response.snapshot.sessionSampleRate, SampleRate(rate)) + } + } + + func testKillSwitchClearsValuesKeepsVersion() throws { + let body = #""" + { "schema_version": 1, "version": 43, "enabled": false, "rum": {}, "custom": { "viplist": ["u-1"] } } + """#.data(using: .utf8)! + + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + let rates = response.snapshot.rates + + XCTAssertEqual(response.snapshot.version, 43) + XCTAssertFalse(response.snapshot.enabled) + XCTAssertNil(response.snapshot.custom) + XCTAssertNil(rates.sessionSampleRate) + XCTAssertNil(rates.custom) + XCTAssertEqual(rates.version, 43, "the version survives the kill switch") + } + + func testRatesReflectSnapshot() throws { + let body = #""" + { "schema_version": 1, "version": 42, "enabled": true, "rum": { "sessionSampleRate": 20 }, "custom": { "a": 1 } } + """#.data(using: .utf8)! + + let rates = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()).snapshot.rates + + XCTAssertEqual(rates.sessionSampleRate, 20) + XCTAssertEqual(rates.version, 42) + XCTAssertEqual(rates.custom, #"{"a":1}"#) + } + + // MARK: - Schema + + func testRefusesASchemaItDoesNotRead() { + let bodies = [ + #"{ "schema_version": 2, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#, + #"{ "schema_version": "1", "version": 1, "enabled": true }"#, // schema of wrong type + #"{ "schema_version": true, "version": 1, "enabled": true }"#, // JSON bools bridge to NSNumber + ] + + for string in bodies { + let body = string.data(using: .utf8)! + XCTAssertThrowsError(try RemoteSamplingResponse.parse(body: body, etag: .mockAny()), "should refuse: \(string)") { error in + // Refused as a schema we cannot read, not as an unreadable body: the two get + // opposite answers from the controller. + XCTAssertTrue(error is RemoteSamplingUnsupportedSchemaError, "should refuse on schema: \(string)") + } + } + } + + func testReadsABodyWithNoSchemaStampAtAll() throws { + // A body with no stamp is, by construction, the shape that existed before the stamp did — + // the shape this reader was written against. Refusing it would switch remote configuration + // silently off for every client on this platform against a server that merely predates the + // field, and nothing would say so. + let bodies = [ + #"{ "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#, // no key at all + #"{ "schema_version": null, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"#, + ] + + for string in bodies { + let body = string.data(using: .utf8)! + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + XCTAssertEqual(response.snapshot.sessionSampleRate, 20, "should read: \(string)") + } + } + + func testReadsTheSchemaItSupports() throws { + let body = #"{ "schema_version": 1, "version": 1, "enabled": true, "rum": { "sessionSampleRate": 20 } }"# + .data(using: .utf8)! + + let response = try RemoteSamplingResponse.parse(body: body, etag: .mockAny()) + + XCTAssertEqual(response.snapshot.sessionSampleRate, 20) + } + + // MARK: - ETag + + func testETagIsQuotedHashPrefix() { + let body = #"{ "version": 1 }"#.data(using: .utf8)! + + let etag = remoteSamplingETag(for: body) + + XCTAssertTrue(etag.hasPrefix("\"")) + XCTAssertTrue(etag.hasSuffix("\"")) + XCTAssertEqual(etag.count, 18, "16 hex characters plus the quotes") + let same = remoteSamplingETag(for: body) + XCTAssertEqual(etag, same, "ETag must be stable for the same body") + let other = remoteSamplingETag(for: #"{ "version": 2 }"#.data(using: .utf8)!) + XCTAssertNotEqual(etag, other) + } + + // MARK: - Persistence + + func testStorageKeyComposition() { + let source = RemoteSamplingSource(configurationURL: URL(string: "https://intake.example.com/api/v2/rum/config?client_token=t")!) + let base: DatadogContext = .mockWith( + clientToken: "t", + service: "shop", + env: "prod", + version: "1.2.3", + sdkVersion: "2.0.0", + applicationBundleIdentifier: "com.example.shop" + ) + let key = RemoteSamplingSnapshotStore.key(source: source, context: base) + + // SDK version is deliberately not part of the key: + let otherSDK: DatadogContext = .mockWith( + clientToken: "t", + service: "shop", + env: "prod", + version: "1.2.3", + sdkVersion: "9.9.9", + applicationBundleIdentifier: "com.example.shop" + ) + XCTAssertEqual(key, RemoteSamplingSnapshotStore.key(source: source, context: otherSDK)) + + // Everything else that identifies a configuration is: + let otherAppVersion: DatadogContext = .mockWith( + clientToken: "t", service: "shop", env: "prod", version: "9.9.9", applicationBundleIdentifier: "com.example.shop" + ) + XCTAssertNotEqual(key, RemoteSamplingSnapshotStore.key(source: source, context: otherAppVersion)) + + let otherEnv: DatadogContext = .mockWith( + clientToken: "t", service: "shop", env: "staging", version: "1.2.3", applicationBundleIdentifier: "com.example.shop" + ) + XCTAssertNotEqual(key, RemoteSamplingSnapshotStore.key(source: source, context: otherEnv)) + + let otherService: DatadogContext = .mockWith( + clientToken: "t", service: "other", env: "prod", version: "1.2.3", applicationBundleIdentifier: "com.example.shop" + ) + XCTAssertNotEqual(key, RemoteSamplingSnapshotStore.key(source: source, context: otherService)) + + let otherHost = RemoteSamplingSource(configurationURL: URL(string: "https://other.example.com/api/v2/rum/config")!) + XCTAssertNotEqual(key, RemoteSamplingSnapshotStore.key(source: otherHost, context: base)) + } + + func testStoreRoundTrip() throws { + CreateTemporaryDirectory() + defer { DeleteTemporaryDirectory() } + + let coreDirectory = CoreDirectory( + osDirectory: Directory(url: temporaryDirectory), + coreDirectory: try Directory(url: temporaryDirectory).createSubdirectory(path: "core-\(UUID().uuidString)") + ) + let store = try RemoteSamplingSnapshotStore(coreDirectory: coreDirectory) + let snapshot = RemoteSamplingSnapshot( + version: 42, + etag: "\"0123456789abcdef\"", + enabled: true, + sessionSampleRate: 20, + custom: #"{"a":1}"# + ) + + store.save(snapshot, forKey: "key") + XCTAssertEqual(store.load(forKey: "key"), snapshot) + XCTAssertNil(store.load(forKey: "other-key")) + } +} diff --git a/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift b/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift index 9217ad938b..b603351878 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMCoreContext.swift @@ -20,6 +20,11 @@ public struct RUMCoreContext: AdditionalContext, Equatable { public let userActionID: String? /// Current view related server time offset public let viewServerTimeOffset: TimeInterval? + /// FLASHCAT FORK - whether the host application forced this session to be collected. Session + /// Replay skips its own draw when it is set, because a forced session must come out with + /// replay: forcing exists to debug one visitor, and a replay-less recording of them is not the + /// thing that was asked for. + public let sessionForced: Bool /// Creates a RUM context. /// @@ -29,17 +34,20 @@ public struct RUMCoreContext: AdditionalContext, Equatable { /// - viewID: Current RUM view ID - standard UUID string, lowercased. It can be empty when view is being loaded. /// - userActionID: The ID of current RUM action (standard UUID `String`, lowercased). /// - viewServerTimeOffset: Current view related server time offset + /// - sessionForced: Whether the host application forced this session to be collected. public init( applicationID: String, sessionID: String, viewID: String? = nil, userActionID: String? = nil, - viewServerTimeOffset: TimeInterval? = nil + viewServerTimeOffset: TimeInterval? = nil, + sessionForced: Bool = false ) { self.applicationID = applicationID self.sessionID = sessionID self.viewID = viewID self.userActionID = userActionID self.viewServerTimeOffset = viewServerTimeOffset + self.sessionForced = sessionForced } } diff --git a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift index 2952c44e51..52d16bc6a3 100644 --- a/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift +++ b/DatadogInternal/Sources/Models/RUM/RUMDataModels.swift @@ -5032,6 +5032,13 @@ public struct RUMViewEvent: RUMDataModel { /// The percentage of sessions profiled public let profilingSampleRate: Double? + // FLASHCAT FORK: `rc_version` is a FlashCat addition on top of the shared schema; our + // intake reads it, others ignore it. It is patched in by hand because the fork does + // not fork the rum-events-format repo — reapplying `make rum-models-generate` will + // drop it and this patch must be restored. + /// The version of the console's remote configuration the session was drawn with + public let rcVersion: Int64? + /// The percentage of sessions with RUM & Session Replay pricing tracked public let sessionReplaySampleRate: Double? @@ -5046,6 +5053,8 @@ public struct RUMViewEvent: RUMDataModel { public enum CodingKeys: String, CodingKey { case profilingSampleRate = "profiling_sample_rate" + // FLASHCAT FORK: see `rcVersion` above. + case rcVersion = "rc_version" case sessionReplaySampleRate = "session_replay_sample_rate" case sessionSampleRate = "session_sample_rate" case startSessionReplayRecordingManually = "start_session_replay_recording_manually" @@ -5056,18 +5065,21 @@ public struct RUMViewEvent: RUMDataModel { /// /// - Parameters: /// - profilingSampleRate: The percentage of sessions profiled + /// - rcVersion: The version of the console's remote configuration the session was drawn with /// - sessionReplaySampleRate: The percentage of sessions with RUM & Session Replay pricing tracked /// - sessionSampleRate: The percentage of sessions tracked /// - startSessionReplayRecordingManually: Whether session replay recording configured to start manually /// - traceSampleRate: The percentage of sessions with traced resources public init( profilingSampleRate: Double? = nil, + rcVersion: Int64? = nil, sessionReplaySampleRate: Double? = nil, sessionSampleRate: Double, startSessionReplayRecordingManually: Bool? = nil, traceSampleRate: Double? = nil ) { self.profilingSampleRate = profilingSampleRate + self.rcVersion = rcVersion self.sessionReplaySampleRate = sessionReplaySampleRate self.sessionSampleRate = sessionSampleRate self.startSessionReplayRecordingManually = startSessionReplayRecordingManually diff --git a/DatadogInternal/Sources/RemoteSampling.swift b/DatadogInternal/Sources/RemoteSampling.swift new file mode 100644 index 0000000000..657609ae3f --- /dev/null +++ b/DatadogInternal/Sources/RemoteSampling.swift @@ -0,0 +1,94 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation + +/// Where to ask for the sampling rates set in the Flashcat console. +/// +/// RUM publishes this when the app opts in, because the address depends on RUM's own endpoint +/// configuration, which the core does not otherwise know. Its presence is also what tells the core +/// there is anything to ask for: with no source published, nothing is fetched and nothing changes. +public struct RemoteSamplingSource: AdditionalContext, Equatable { + public static let key = "remote-sampling-source" + + /// The full configuration URL, including the query the server matches rules on. + public let configurationURL: URL + + public init(configurationURL: URL) { + self.configurationURL = configurationURL + } +} + +/// The configuration values the console last provided. +/// +/// The core is the only writer and RUM the only reader: it draws each new session against these +/// values. Session Replay is not a reader — it samples with the rate the app configured and follows +/// RUM's decision about the session — so a replay rate is not delivered on this fork. A knob is +/// absent — never zero — when the console did not set it, and the feature then keeps the value the +/// app was initialised with. Reporting a zero we invented would silently stop collection nobody +/// asked to stop. +public struct RemoteSamplingRates: AdditionalContext, Equatable { + public static let key = "remote-sampling-rates" + + public let sessionSampleRate: SampleRate? + + /// The version of the console configuration these values came from. + /// + /// It survives the kill switch: when the console disables remote configuration the values are + /// cleared but the version is kept, so the client can still report which version it runs. + /// `0` means the console never provided a configuration. + public let version: Int64 + + /// The console's custom values, as the raw JSON object they were delivered in. + /// + /// Delivery is the platform's job; the meaning belongs to the host application, which reads + /// them through `RUMMonitorProtocol.getRemoteConfig()`. Kept as raw JSON so any value shape the + /// console adds later reaches the app without an SDK update. + public let custom: String? + + public init( + sessionSampleRate: SampleRate?, + version: Int64 = 0, + custom: String? = nil + ) { + self.sessionSampleRate = sessionSampleRate + self.version = version + self.custom = custom + } +} + +/// Sent by the core when the console asked for a change to take effect immediately and the rates +/// this app will now draw with really changed. +/// +/// RUM answers it by ending the running session so a new one starts under the new rates. Ending and +/// restarting is deliberate: a session that was not being collected has no id and no history, so +/// flipping its decision in place would invent a session that appears to begin mid-use, and a +/// collected session flipped off would simply stop, looking like it ended early. +public struct RemoteSamplingChangedMessage { + public init() {} +} + +/// Synchronous access to the remote sampling configuration a previous launch stored. +/// +/// The session draw is synchronous; the context that carries these rates to features is not. A +/// value published onto the context queue becomes visible some time AFTER the draw that needed it, +/// so a feature reading only the context would draw the first session of every launch under the +/// values the app was built with, and take the console's setting from the second session on. That +/// is precisely the case the on-disk snapshot exists to cover, so it has to be readable without a +/// queue hop. +public protocol RemoteSamplingReader: AnyObject { + /// Loads what a previous launch stored, without waiting on any queue, so the first draw of + /// this launch already sees it. The load happens once; later calls are cheap. + /// + /// - Parameter source: builds the address to load for from the context, which the core reads + /// synchronously. Returning nil means there is nothing to load. + /// - Returns: the rates now in effect, or nil when nothing was stored. + @discardableResult + func primeRemoteSampling(source: (DatadogContext) -> RemoteSamplingSource?) -> RemoteSamplingRates? + + /// The rates in effect right now, readable synchronously. + var remoteSamplingRates: RemoteSamplingRates? { get } +} diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 670bdde85c..8468de1025 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -42,6 +42,20 @@ internal final class RUMFeature: DatadogRemoteFeature { ) let featureScope = core.scope(for: RUMFeature.self) + + // FLASHCAT FORK - read what a previous launch stored BEFORE the first session can be + // drawn. The context is how these rates reach other features, but it is written on its own + // queue, so a value published there is not yet visible to the draw that needs it — and the + // first session of a launch is drawn immediately after `RUM.enable()`. Without this, every + // cold start would draw its first session at the values the app was built with and only + // take the console's setting from the second session on. + let remoteSamplingReader: RemoteSamplingReader? = configuration.remoteConfigurationEnabled + ? core as? RemoteSamplingReader + : nil + let primedRemoteSampling = remoteSamplingReader?.primeRemoteSampling { context in + remoteSamplingConfigurationURL(customEndpoint: configuration.customEndpoint, context: context) + .map { RemoteSamplingSource(configurationURL: $0) } + } let sessionEndedMetric = SessionEndedMetricController( telemetry: core.telemetry, sampleRate: configuration.debugSDK ? 100 : configuration.sessionEndedSampleRate, @@ -174,7 +188,11 @@ internal final class RUMFeature: DatadogRemoteFeature { predicate: nextViewActionPredicate ) }, - sessionType: configuration.sessionTypeOverride.flatMap { RUMSessionType(rawValue: $0) } + sessionType: configuration.sessionTypeOverride.flatMap { RUMSessionType(rawValue: $0) }, + remoteConfigurationEnabled: configuration.remoteConfigurationEnabled, + customEndpoint: configuration.customEndpoint, + remoteSamplingRates: { [weak remoteSamplingReader] in remoteSamplingReader?.remoteSamplingRates }, + beforeSampling: configuration.beforeSampling ) self.monitor = Monitor( @@ -182,6 +200,14 @@ internal final class RUMFeature: DatadogRemoteFeature { dateProvider: configuration.dateProvider ) + // FLASHCAT FORK - seed the custom values from the same synchronous read that primed the + // rates. They reach the monitor again on the next context broadcast, but that arrives on + // another queue, so an app calling `getRemoteConfig()` straight after `RUM.enable()` would + // be told nothing was published while the very same stored configuration was already + // deciding how its first session was drawn. Reading them to choose whether to force a + // session is exactly what the API is documented for, and that call happens at start-up. + self.monitor.remoteConfigCustom = primedRemoteSampling?.custom + if let refreshRateVital = dependencies.vitalsReaders?.refreshRate as? RenderLoopReader { dependencies.renderLoopObserver?.register(refreshRateVital) } @@ -234,6 +260,7 @@ internal final class RUMFeature: DatadogRemoteFeature { featureScope: featureScope, monitor: monitor ), + RemoteSamplingReceiver(monitor: monitor), FlagEvaluationReceiver(monitor: monitor), WebViewEventReceiver( featureScope: featureScope, diff --git a/DatadogRUM/Sources/Integrations/RemoteSamplingReceiver.swift b/DatadogRUM/Sources/Integrations/RemoteSamplingReceiver.swift new file mode 100644 index 0000000000..88bc44db32 --- /dev/null +++ b/DatadogRUM/Sources/Integrations/RemoteSamplingReceiver.swift @@ -0,0 +1,36 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import DatadogInternal +import Foundation + +/// Receives the core's remote-sampling signals for the RUM feature. +/// +/// Two kinds of messages matter here: +/// - `RemoteSamplingChangedMessage`, sent when the console asked for an immediate change to the +/// rates this app draws with. RUM answers by ending the running session, so the next one starts +/// under the new rates. Ending and restarting is deliberate: a session that was not being +/// collected has no id and no history, so flipping its decision in place would invent a session +/// that appears to begin mid-use, and a collected session flipped off would simply stop, looking +/// like it ended early. +/// - context updates, which carry the console's custom values for the host application to read +/// through `RUMMonitorProtocol.getRemoteConfig()`. +internal struct RemoteSamplingReceiver: FeatureMessageReceiver { + let monitor: Monitor + + func receive(message: FeatureMessage, from core: DatadogCoreProtocol) -> Bool { + switch message { + case .payload(let payload as RemoteSamplingChangedMessage): + monitor.stopSession() + return true + case .context(let context): + monitor.remoteConfigCustom = context.additionalContext(ofType: RemoteSamplingRates.self)?.custom + return false // context updates are broadcast; claiming them would only suppress the fallback + default: + return false + } + } +} diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 9332491d99..b528253c09 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -306,6 +306,26 @@ extension RUM { /// Default: `false`. public var collectAccessibility: Bool + /// Enables the remote configuration of sampling rates from the console. + /// + /// When enabled, the SDK asks the console (at startup and whenever a new session starts) + /// for the sampling rates to draw new sessions with, and reports the configuration version + /// each session was drawn under on its view events. The values the app was initialised + /// with stay in effect for anything the console does not set. + /// + /// Default: `false` — no extra requests are made and behaviour is unchanged. + public var remoteConfigurationEnabled: Bool + + /// Has the last word on session sampling. + /// + /// Called synchronously each time a new session is about to be drawn, with the rate that + /// would apply and the console's custom values; return a rate to override it, or nil to + /// leave it alone. It is the last step of the draw, after the console's rate, precisely so + /// an allow-list can keep collecting a visitor the console's rate would drop. + /// + /// Default: `nil` — the draw is exactly what the console and this configuration say. + public var beforeSampling: BeforeSamplingCallback? + /// Feature flags to preview features in RUM. public var featureFlags: FeatureFlags @@ -465,6 +485,8 @@ extension RUM.Configuration { /// - trackSlowFrames: Enables the collection of slow frames (view hitches). Default: `true`. /// - telemetrySampleRate: The sampling rate for SDK internal telemetry utilized by Datadog. Must be a value between `0` and `100`. Default: `20`. /// - collectAccessibility: Determines whether accessibility data should be collected and included in RUM view events. Default: `false`. + /// - remoteConfigurationEnabled: Enables remote configuration of sampling rates from the console. Default: `false`. + /// - beforeSampling: Has the last word on session sampling. Default: `nil`. /// - featureFlags: Experimental feature flags. public init( applicationID: String, @@ -494,6 +516,8 @@ extension RUM.Configuration { trackSlowFrames: Bool = true, telemetrySampleRate: SampleRate = 20, collectAccessibility: Bool = false, + remoteConfigurationEnabled: Bool = false, + beforeSampling: BeforeSamplingCallback? = nil, featureFlags: FeatureFlags = .defaults ) { self.applicationID = applicationID @@ -523,6 +547,8 @@ extension RUM.Configuration { self.trackSlowFrames = trackSlowFrames self.telemetrySampleRate = telemetrySampleRate self.collectAccessibility = collectAccessibility + self.remoteConfigurationEnabled = remoteConfigurationEnabled + self.beforeSampling = beforeSampling self.featureFlags = featureFlags } } @@ -560,3 +586,31 @@ extension RUM.Configuration.FeatureFlags { self[flag, default: false] } } + +/// What the SDK is about to draw a new session with, handed to `RUM.Configuration.beforeSampling`: +/// the rate that would apply (the console's where it published one, the value passed to init where +/// it did not) and the console's custom values, decoded. +public struct BeforeSamplingContext { + /// The rate, between 0 and 100, that would decide this session. + public let sessionSampleRate: SampleRate + /// The console's custom values, or nil when remote configuration is off or nothing is + /// published. The same content as `RUMMonitorProtocol.getRemoteConfig()`. + public let custom: [String: Any]? + + public init(sessionSampleRate: SampleRate, custom: [String: Any]?) { + self.sessionSampleRate = sessionSampleRate + self.custom = custom + } +} + +/// The application's last word on session sampling, called synchronously each time a new session +/// is about to be drawn. +/// +/// Return a rate to override the one the SDK was going to use — 100 always collects, 0 never does +/// — or nil to leave it alone. The typical use is an allow-list: keep every session of the handful +/// of users you are debugging while the fleet stays at a low rate. +/// +/// It runs inside session creation, so it must be fast and must not block. A rate outside 0...100 +/// is ignored and the incoming rate applies: a mistake here must never take a customer's +/// collection down with it. A session already under way is never re-decided. +public typealias BeforeSamplingCallback = (BeforeSamplingContext) -> SampleRate? diff --git a/DatadogRUM/Sources/RUMContext/RUMContext.swift b/DatadogRUM/Sources/RUMContext/RUMContext.swift index b52e03a616..05da26d66f 100644 --- a/DatadogRUM/Sources/RUMContext/RUMContext.swift +++ b/DatadogRUM/Sources/RUMContext/RUMContext.swift @@ -25,4 +25,20 @@ internal struct RUMContext { var activeViewName: String? /// The ID of active user action. var activeUserActionID: RUMUUID? + + /// The configuration the current session was drawn with; `nil` when nothing moved the draw + /// away from the value the app was initialised with. Fixed for the session's life — it never + /// changes mid-session. + var drawnConfiguration: RUMDrawnConfiguration? + + /// The rate this session was actually drawn with, which is what every event it produces + /// reports. Falls back to the value the app was initialised with, because that is then the + /// rate that decided it. + func reportedSessionSampleRate(initialisedWith sampler: Sampler) -> Double { + Double(drawnConfiguration?.sessionSampleRate ?? sampler.samplingRate) + } + + /// Whether the host application forced this session to be collected. Session Replay reads it + /// through the core context so a forced session comes out with replay. + var sessionForced: Bool = false } diff --git a/DatadogRUM/Sources/RUMMonitor/Monitor.swift b/DatadogRUM/Sources/RUMMonitor/Monitor.swift index afd64a432e..cb202e750c 100644 --- a/DatadogRUM/Sources/RUMMonitor/Monitor.swift +++ b/DatadogRUM/Sources/RUMMonitor/Monitor.swift @@ -111,6 +111,14 @@ internal class Monitor: RUMCommandSubscriber { private let rumUUIDGenerator: RUMUUIDGenerator private let telemetry: Telemetry + /// The console's custom values, as the raw JSON object they were delivered in. + /// + /// Kept up to date by `RemoteSamplingReceiver` from the rates the core publishes; read by the + /// host application through `RUMMonitorProtocol.getRemoteConfig()`. `nil` when remote + /// configuration is off, when the console set no custom values, or after a kill switch. + @ReadWriteLock + var remoteConfigCustom: String? + init( dependencies: RUMScopeDependencies, dateProvider: DateProvider @@ -162,7 +170,8 @@ internal class Monitor: RUMCommandSubscriber { sessionID: context.sessionID.rawValue.uuidString.lowercased(), viewID: context.activeViewID?.rawValue.uuidString.lowercased(), userActionID: context.activeUserActionID?.rawValue.uuidString.lowercased(), - viewServerTimeOffset: self.scopes.activeSession?.viewScopes.last?.serverTimeOffset + viewServerTimeOffset: self.scopes.activeSession?.viewScopes.last?.serverTimeOffset, + sessionForced: context.sessionForced ) } ) @@ -241,6 +250,20 @@ extension Monitor: RUMMonitorProtocol { process(command: RUMStopSessionCommand(time: dateProvider.now)) } + func setForcedSession() { + process(command: RUMSetForcedSessionCommand(time: dateProvider.now)) + } + + func getRemoteConfig() -> [String: Any]? { + guard let json = remoteConfigCustom, + let data = json.data(using: .utf8), + let values = try? JSONSerialization.jsonObject(with: data), + let dictionary = values as? [String: Any] else { + return nil + } + return dictionary + } + func reportAppFullyDisplayed() { process(command: RUMTimeToFullDisplayCommand(time: dateProvider.now)) } diff --git a/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift b/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift index 970956d565..08d84871e3 100644 --- a/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift +++ b/DatadogRUM/Sources/RUMMonitor/RUMCommand.swift @@ -62,6 +62,21 @@ internal struct RUMApplicationStartCommand: RUMCommand { let missedEventType: SessionEndedMetric.MissedEventType? = nil } +/// FLASHCAT FORK - `RUMMonitorProtocol.setForcedSession()`: from here on every draw keeps the +/// session, for the lifetime of the process. +internal struct RUMSetForcedSessionCommand: RUMCommand { + var time: Date + var globalAttributes: [AttributeKey: AttributeValue] = [:] + var attributes: [AttributeKey: AttributeValue] = [:] + var canStartApplicationLaunchView = false + let canStartBackgroundView = false + let shouldRestartLastViewAfterSessionExpiration = false + let shouldRestartLastViewAfterSessionStop = false + let canStartBackgroundViewAfterSessionStop = false + let isUserInteraction = false + let missedEventType: SessionEndedMetric.MissedEventType? = nil +} + internal struct RUMStopSessionCommand: RUMCommand { var time: Date var globalAttributes: [AttributeKey: AttributeValue] = [:] diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift index c149408b98..8762fa772b 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift @@ -21,6 +21,11 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { /// Might be re-created later according to session duration constraints. private(set) var sessionScopes: [RUMSessionScope] = [] + /// FLASHCAT FORK - set through `RUMMonitorProtocol.setForcedSession()`, read at every draw from + /// then on. Process-lifetime, like the debugging decision it represents: the host application + /// decides again on each launch. + private(set) var isForcedSession = false + /// The last active foreground view from the previous session. /// Used to restore the view when a new session starts after `sessionStop()`. private var lastActiveView: RUMViewScope? @@ -149,6 +154,17 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { let lastActiveForegroundView = activeSession?.viewScopes.first(where: { $0.isActiveView && $0.viewPath != RUMOffViewEventsHandlingRule.Constants.backgroundViewURL }) lastActiveView = lastActiveForegroundView ?? lastActiveView + if let forced = command as? RUMSetForcedSessionCommand { + isForcedSession = true + // A session already being collected keeps running: RUM cannot retro-collect what a + // running session already dropped, and cutting it in two would gain nothing. One that + // was NOT collected ends now, so a collected one starts in its place. + if activeSession?.isSampled != true { + _process(command: RUMStopSessionCommand(time: forced.time), context: context, writer: writer) + } + return + } + if command is RUMStopSessionCommand { applicationState.wasAnySessionStopped = true } @@ -234,7 +250,8 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { startPrecondition: startPrecondition, context: context, dependencies: dependencies, - applicationState: applicationState + applicationState: applicationState, + isForced: isForcedSession ) lastSessionEndReason = nil @@ -266,7 +283,8 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { startPrecondition: startPrecondition, context: context, transferActiveView: transferActiveView, - applicationState: applicationState + applicationState: applicationState, + isForced: isForcedSession ) sessionScopeDidUpdate(refreshedSession) lastActiveView = nil @@ -310,6 +328,7 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { context: context, dependencies: dependencies, applicationState: applicationState, + isForced: isForcedSession, resumingViewScope: resumeViewScope ? lastActiveView : nil ) lastActiveView = nil diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift new file mode 100644 index 0000000000..eeeb7c2174 --- /dev/null +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMDrawnConfiguration.swift @@ -0,0 +1,65 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import DatadogInternal +import Foundation + +/// The configuration a RUM session was drawn with, fixed for the session's life. +/// +/// Sessions never flip: the draw happens once, at session creation, from the remote rates the core +/// last published (falling back to the value the app was initialised with when the console set +/// none), and the record below is what view events report. +internal struct RUMDrawnConfiguration: Equatable { + /// The session sample rate the session was drawn with — remote when set, init otherwise. + let sessionSampleRate: SampleRate + /// The console configuration version the session was drawn with (`rc_version`); 0 when no + /// remote configuration was in effect. + let version: Int64 + + /// Records the draw that just happened. + /// + /// `nil` only when there is nothing to record — no configuration was in effect and the draw + /// used the value the app was initialised with, so events reporting that value are already + /// telling the truth, exactly as before remote configuration existed. + /// + /// A `beforeSampling` hook is reason enough on its own: it applies whether or not the console + /// is publishing anything, so an app that only uses the hook still draws at a rate that is not + /// the init value, and without a record every event would report the init value instead of the + /// rate that actually decided the session. + /// + /// - Parameters: + /// - rates: what the console had published at the moment of the draw. + /// - drawnSessionSampleRate: the rate the draw actually used — the console's, the init value, + /// or whatever `beforeSampling` returned. It is what the events report, because it is what + /// decided the session. + /// - initialSessionSampleRate: the value the app was initialised with, to tell "nothing + /// happened" from "the draw moved". + init?(rates: RemoteSamplingRates?, drawnSessionSampleRate: SampleRate, initialSessionSampleRate: SampleRate) { + guard rates != nil || drawnSessionSampleRate != initialSessionSampleRate else { + return nil + } + self.sessionSampleRate = drawnSessionSampleRate + self.version = rates?.version ?? 0 + } +} + +/// Builds the URL the core asks for the remote sampling configuration. +/// +/// A custom endpoint means the app was pointed at the customer's own host for the RUM intake, and +/// the configuration lives beside it there — which is exactly the layout the private-deployment +/// nginx template serves. +internal func remoteSamplingConfigurationURL(customEndpoint: URL?, context: DatadogContext) -> URL? { + let intake = customEndpoint ?? context.site.endpoint.appendingPathComponent("api/v2/rum") + var components = URLComponents(url: intake.appendingPathComponent("config"), resolvingAgainstBaseURL: false) + components?.queryItems = [ + URLQueryItem(name: "client_token", value: context.clientToken), + URLQueryItem(name: "sdk", value: "ios"), + URLQueryItem(name: "sdk_version", value: context.sdkVersion), + URLQueryItem(name: "env", value: context.env), + URLQueryItem(name: "app_version", value: context.version) + ] + return components?.url +} diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift index 546f62df36..640b23c862 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMResourceScope.swift @@ -173,7 +173,7 @@ internal class RUMResourceScope: RUMScope { browserSdkVersion: nil, configuration: .init( sessionReplaySampleRate: nil, - sessionSampleRate: Double(dependencies.sessionSampler.samplingRate) + sessionSampleRate: parent.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler) ), discarded: nil, rulePsr: traceSamplingRate, @@ -294,7 +294,7 @@ internal class RUMResourceScope: RUMScope { let errorEvent = RUMErrorEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: parent.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler)), session: .init( plan: .plan1, sessionPrecondition: parent.context.sessionPrecondition diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift index fa3bbd7373..d65f6dec7d 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift @@ -57,6 +57,19 @@ internal struct RUMScopeDependencies { let appStateManager: AppStateManaging let watchdogTermination: WatchdogTerminationMonitor? + /// Whether the console may set sampling rates remotely. When `false` (the default) no + /// configuration is ever requested and every session draws with the init values. + let remoteConfigurationEnabled: Bool + /// The custom RUM intake the configuration endpoint sits next to, when the app set one. + let customEndpoint: URL? + /// The console's rates as they stand right now, read synchronously. + /// + /// A closure rather than a value: the draw has to see what is in effect at the draw, and the + /// core updates it whenever the console answers. Always nil when the app did not opt in. + let remoteSamplingRates: () -> RemoteSamplingRates? + /// The host application's last word on the draw, consulted after the console's rate. + let beforeSampling: BeforeSamplingCallback? + /// A factory function that creates `ViewEndedMetricController` for each new view started. let viewEndedMetricFactory: () -> ViewEndedController @@ -96,7 +109,11 @@ internal struct RUMScopeDependencies { watchdogTermination: WatchdogTerminationMonitor?, networkSettledMetricFactory: @escaping (Date, String) -> TNSMetricTracking, interactionToNextViewMetricFactory: @escaping () -> INVMetricTracking?, - sessionType: RUMSessionType? + sessionType: RUMSessionType?, + remoteConfigurationEnabled: Bool = false, + customEndpoint: URL? = nil, + remoteSamplingRates: @escaping () -> RemoteSamplingRates? = { nil }, + beforeSampling: BeforeSamplingCallback? = nil ) { self.featureScope = featureScope self.rumApplicationID = rumApplicationID @@ -123,6 +140,10 @@ internal struct RUMScopeDependencies { self.viewEndedMetricFactory = viewEndedMetricFactory self.appStateManager = appStateManager self.watchdogTermination = watchdogTermination + self.remoteConfigurationEnabled = remoteConfigurationEnabled + self.customEndpoint = customEndpoint + self.remoteSamplingRates = remoteSamplingRates + self.beforeSampling = beforeSampling self.networkSettledMetricFactory = networkSettledMetricFactory self.interactionToNextViewMetricFactory = interactionToNextViewMetricFactory diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index 001bc7519a..be951d2cf7 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -82,6 +82,13 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { let startPrecondition: RUMSessionPrecondition? /// If events from this session should be sampled (send to Datadog). let isSampled: Bool + /// If the host application forced this session to be collected through + /// `RUMMonitorProtocol.setForcedSession()`. Session Replay reads it so a forced session comes + /// out with replay rather than being dropped by replay's own draw. + let isForced: Bool + /// The configuration this session was drawn with; `nil` when no remote configuration is in + /// effect. Drawn once here and fixed for the session's life — sessions never flip. + let drawnConfiguration: RUMDrawnConfiguration? /// If the session is currently active. Set to `false` upon reaching the `EndReason`. var isActive: Bool { endReason == nil } /// If this is the very first session created in the current app process (`false` for session created upon expiration of a previous one). @@ -108,12 +115,35 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { context: DatadogContext, dependencies: RUMScopeDependencies, applicationState: RUMApplicationState, + isForced: Bool = false, resumingViewScope: RUMViewScope? = nil ) { self.parent = parent self.dependencies = dependencies self.applicationState = applicationState - self.isSampled = dependencies.sessionSampler.sample() + self.isForced = isForced + // The session draw, in the order the three sources are allowed to speak: the console's + // rate when it published one, the init value otherwise, and finally the app's own hook. + // The hook is the last word precisely so an allow-list can keep collecting a visitor the + // console's rate would drop. + // + // The rates are read synchronously rather than from `context`: the context carries them to + // other features, but it is written on its own queue, so a value published there is not + // visible to THIS draw — which is the draw the stored snapshot exists for. + let remoteRates = dependencies.remoteSamplingRates() + ?? context.additionalContext(ofType: RemoteSamplingRates.self) + let drawnRate = Self.resolveSampleRate( + remoteRates: remoteRates, + dependencies: dependencies + ) + // A forced session skips the draw entirely: the app has said this visitor must be + // collected, and a coin flip could still say no. + self.isSampled = isForced || Sampler(samplingRate: drawnRate).sample() + self.drawnConfiguration = RUMDrawnConfiguration( + rates: remoteRates, + drawnSessionSampleRate: drawnRate, + initialSessionSampleRate: dependencies.sessionSampler.samplingRate + ) self.startPrecondition = startPrecondition self.sessionUUID = isSampled ? dependencies.rumUUIDGenerator.generateUnique() : .nullUUID self.isInitialSession = isInitialSession @@ -152,6 +182,15 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { // Update fatal error context with recent RUM session state: dependencies.fatalErrorContext.sessionState = state + + // Every session creation is one opportunity to ask the console for the sampling + // configuration. Publishing the source is the whole signal: the core deduplicates a + // fetch in flight and applies its retry policy, and with the option off nothing is + // ever published, so nothing is ever requested. + if dependencies.remoteConfigurationEnabled, + let url = remoteSamplingConfigurationURL(customEndpoint: dependencies.customEndpoint, context: context) { + dependencies.featureScope.set(context: { RemoteSamplingSource(configurationURL: url) }) + } } /// Creates a new Session upon expiration of the previous one. @@ -161,7 +200,8 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { startPrecondition: RUMSessionPrecondition?, context: DatadogContext, transferActiveView: Bool, - applicationState: RUMApplicationState + applicationState: RUMApplicationState, + isForced: Bool = false ) { self.init( // If the expired session was marked as "initial" but didn’t track any views, mark this new session as the new "initial". @@ -171,7 +211,8 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { startPrecondition: startPrecondition, context: context, dependencies: expiredSession.dependencies, - applicationState: applicationState + applicationState: applicationState, + isForced: isForced ) // Transfer active View to new `RUMViewScope`: @@ -199,6 +240,32 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { } } + /// The rate this session is drawn at: the console's where it published one, the init value + /// otherwise, with the host application's hook having the final say. + private static func resolveSampleRate( + remoteRates: RemoteSamplingRates?, + dependencies: RUMScopeDependencies + ) -> SampleRate { + let base = remoteRates?.sessionSampleRate ?? dependencies.sessionSampler.samplingRate + guard let hook = dependencies.beforeSampling else { + return base + } + let custom = remoteRates?.custom + .flatMap { $0.data(using: .utf8) } + .flatMap { try? JSONSerialization.jsonObject(with: $0) } + .flatMap { $0 as? [String: Any] } + guard let override = hook(BeforeSamplingContext(sessionSampleRate: base, custom: custom)) else { + return base + } + // A rate we cannot trust is not a rate to sample a customer's traffic with, and a mistake + // in the host application must never take their collection down with it. + guard override >= 0, override <= 100 else { + dependencies.telemetry.error("beforeSampling returned \(override), which is not a rate; drawing at \(base) instead") + return base + } + return override + } + // MARK: - RUMContextProvider var context: RUMContext { @@ -206,6 +273,8 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { context.sessionID = sessionUUID context.isSessionActive = isActive context.sessionPrecondition = startPrecondition + context.drawnConfiguration = drawnConfiguration + context.sessionForced = isForced return context } diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift index 4a74d2fe1a..e1a02fbf18 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMUserActionScope.swift @@ -155,7 +155,7 @@ internal class RUMUserActionScope: RUMScope, RUMContextProvider { dd: .init( action: nil, browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: self.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler)), session: .init( plan: .plan1, sessionPrecondition: self.context.sessionPrecondition diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift index bbcc039a11..8f6374e9dc 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift @@ -564,13 +564,21 @@ extension RUMViewScope { // Retrieve Session Replay config if any let sessionReplayConfig = context.additionalContext(ofType: SessionReplayCoreContext.Configuration.self) + // The configuration this view's session was drawn with. The drawn session rate wins over + // the init value so extrapolation and audits line up with the draw that kept the session, + // and `rc_version` lets an auditor recover the exact console settings from its version + // history. With no remote configuration in effect the init values are reported, exactly + // as before remote configuration existed. + let drawnConfiguration = self.context.drawnConfiguration + let viewEvent = RUMViewEvent( dd: .init( browserSdkVersion: nil, cls: nil, configuration: .init( + rcVersion: drawnConfiguration.flatMap { $0.version > 0 ? $0.version : nil }, sessionReplaySampleRate: sessionReplayConfig.map { Double($0.sampleRate) }, - sessionSampleRate: Double(dependencies.sessionSampler.samplingRate), + sessionSampleRate: self.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler), startSessionReplayRecordingManually: sessionReplayConfig?.startRecordingManually, traceSampleRate: context.additionalContext(ofType: TraceCoreContext.Configuration.self) .map { Double($0.sampleRate) } @@ -731,7 +739,7 @@ extension RUMViewScope { let errorEvent = RUMErrorEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: self.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler)), session: .init( plan: .plan1, sessionPrecondition: self.context.sessionPrecondition @@ -822,7 +830,7 @@ extension RUMViewScope { let longTaskEvent = RUMLongTaskEvent( dd: .init( browserSdkVersion: nil, - configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: Double(dependencies.sessionSampler.samplingRate)), + configuration: .init(sessionReplaySampleRate: nil, sessionSampleRate: self.context.reportedSessionSampleRate(initialisedWith: dependencies.sessionSampler)), discarded: nil, session: .init( plan: .plan1, diff --git a/DatadogRUM/Sources/RUMMonitorProtocol.swift b/DatadogRUM/Sources/RUMMonitorProtocol.swift index c5bb8497de..92706c06d8 100644 --- a/DatadogRUM/Sources/RUMMonitorProtocol.swift +++ b/DatadogRUM/Sources/RUMMonitorProtocol.swift @@ -83,6 +83,29 @@ public protocol RUMMonitorProtocol: RUMMonitorViewProtocol, AnyObject { /// If the session is started because of a call to `addAction`, the last known view is restarted in the new session. func stopSession() + /// Forces the session to be collected, with Session Replay, regardless of the configured + /// sample rates. + /// + /// Call it when your own code decides a user needs debugging (an allow-list, a support flow). + /// A session that was not being collected ends and a collected one starts in its place; a + /// session already being collected keeps running, because RUM cannot retro-collect what a + /// running session already dropped. Calling again while the forced session runs does nothing. + /// + /// The forced state lasts for the process lifetime, so decide on each app start whether to + /// call again. + func setForcedSession() + + /// The custom values delivered with the console's remote configuration. + /// + /// Delivery is the SDK's job; the meaning of the values belongs to the application. They are + /// returned as decoded JSON (strings, numbers, booleans, arrays and dictionaries), exactly as + /// the console sent them. + /// + /// Returns `nil` when remote configuration is not enabled + /// (`RUM.Configuration.remoteConfigurationEnabled`), when the console set no custom values, + /// or after the console's kill switch. + func getRemoteConfig() -> [String: Any]? + /// Records the time to full display (TTFD) of the current app launch. /// The duration of the TTFD is calculated as the number of nanoseconds elapsed between the start of the app and the time of this call. func reportAppFullyDisplayed() @@ -459,6 +482,8 @@ internal class NOPMonitor: RUMMonitorProtocol { func removeAttribute(forKey key: AttributeKey) { warn() } func removeAttributes(forKeys keys: [AttributeKey]) {warn() } func stopSession() { warn() } + func setForcedSession() { warn() } + func getRemoteConfig() -> [String: Any]? { warn(); return nil } func reportAppFullyDisplayed() { warn() } func addError(message: String, type: String?, stack: String?, source: RUMErrorSource, attributes: [AttributeKey: AttributeValue], file: StaticString?, line: UInt?) { warn() } func addError(error: Error, source: RUMErrorSource, attributes: [AttributeKey: AttributeValue]) { warn() } diff --git a/DatadogRUM/Tests/Integrations/RemoteSamplingReceiverTests.swift b/DatadogRUM/Tests/Integrations/RemoteSamplingReceiverTests.swift new file mode 100644 index 0000000000..7c8fc06999 --- /dev/null +++ b/DatadogRUM/Tests/Integrations/RemoteSamplingReceiverTests.swift @@ -0,0 +1,73 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import XCTest +import DatadogInternal +@testable import DatadogRUM +@testable import TestUtilities + +class RemoteSamplingReceiverTests: XCTestCase { + private let featureScope = FeatureScopeMock() + + private lazy var monitor = Monitor( + dependencies: .mockWith(featureScope: featureScope), + dateProvider: DateProviderMock() + ) + + private lazy var receiver = RemoteSamplingReceiver(monitor: monitor) + + func testImmediateChangeMessageEndsRunningSession() throws { + monitor.notifySDKInit() + try XCTSkipIf(monitor.scopes.activeSession == nil, "no session to end") + + let handled = receiver.receive(message: .payload(RemoteSamplingChangedMessage()), from: NOPDatadogCore()) + + XCTAssertTrue(handled) + XCTAssertNil(monitor.scopes.activeSession, "the session ends so the next one starts under the new rates") + } + + func testContextMessageKeepsCustomValuesForHostApp() throws { + let rates = RemoteSamplingRates( + sessionSampleRate: nil, + version: 42, + custom: #"{"viplist":["u-1"],"flag":true}"# + ) + var context: DatadogContext = .mockAny() + context.set(additionalContext: rates) + + let handled = receiver.receive(message: .context(context), from: NOPDatadogCore()) + + XCTAssertFalse(handled, "context updates are broadcast, not claimed") + let remoteConfig = try XCTUnwrap(monitor.getRemoteConfig()) + XCTAssertEqual(remoteConfig["viplist"] as? [String], ["u-1"]) + XCTAssertEqual(remoteConfig["flag"] as? Bool, true) + } + + func testKillSwitchDropsCustomValuesForHostApp() { + var context: DatadogContext = .mockAny() + context.set(additionalContext: RemoteSamplingRates( + sessionSampleRate: nil, + version: 42, + custom: #"{"a":1}"# + )) + _ = receiver.receive(message: .context(context), from: NOPDatadogCore()) + XCTAssertNotNil(monitor.getRemoteConfig()) + + // Kill switch: values cleared, version kept, custom gone. + context.set(additionalContext: RemoteSamplingRates( + sessionSampleRate: nil, + version: 43, + custom: nil + )) + _ = receiver.receive(message: .context(context), from: NOPDatadogCore()) + + XCTAssertNil(monitor.getRemoteConfig()) + } + + func testNOPMonitorAnswersNilRemoteConfig() { + XCTAssertNil(NOPMonitor().getRemoteConfig()) + } +} diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift index c8742c9b1d..750c590f21 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift @@ -211,6 +211,96 @@ class RUMApplicationScopeTests: XCTestCase { XCTAssertNil(scope.activeSession) } + // MARK: - Forced session + + func testGivenUncollectedSession_whenForced_itEndsItSoACollectedOneReplacesIt() throws { + // Given: a session the draw threw away + let currentTime = Date() + let scope = createRUMApplicationScope(dependencies: .mockWith(sessionSampler: .mockRejectAll())) + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(1), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + XCTAssertEqual(scope.activeSession?.isSampled, false) + + // When + _ = scope.process( + command: RUMSetForcedSessionCommand(time: currentTime.addingTimeInterval(2)), + context: .mockAny(), + writer: writer + ) + + // Then: the uncollected session is gone, and the next interaction starts a forced one + XCTAssertTrue(scope.isForcedSession) + XCTAssertNil(scope.activeSession) + + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(3), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + XCTAssertEqual(scope.activeSession?.isSampled, true, "the replacement session is collected despite a 0% draw") + XCTAssertEqual(scope.activeSession?.isForced, true) + } + + func testGivenCollectedSession_whenForced_itKeepsRunning() throws { + // RUM cannot retro-collect what a running session already dropped, so cutting a session + // that is already being collected in two would gain nothing. + let currentTime = Date() + let scope = createRUMApplicationScope(dependencies: .mockWith(sessionSampler: .mockKeepAll())) + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(1), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + let sessionBefore = try XCTUnwrap(scope.activeSession?.sessionUUID) + + _ = scope.process( + command: RUMSetForcedSessionCommand(time: currentTime.addingTimeInterval(2)), + context: .mockAny(), + writer: writer + ) + + XCTAssertTrue(scope.isForcedSession) + XCTAssertEqual(scope.activeSession?.sessionUUID, sessionBefore, "the running session is untouched") + } + + func testForcedStateOutlivesTheSessionThatSetIt() throws { + let currentTime = Date() + let scope = createRUMApplicationScope(dependencies: .mockWith(sessionSampler: .mockRejectAll())) + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(1), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + _ = scope.process( + command: RUMSetForcedSessionCommand(time: currentTime.addingTimeInterval(2)), + context: .mockAny(), + writer: writer + ) + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(3), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + + // A later stop must not hand the visitor back to the 0% draw: the forced state is + // process-lifetime, decided again only on the next launch. + _ = scope.process( + command: RUMStopSessionCommand.mockWith(time: currentTime.addingTimeInterval(4)), + context: .mockAny(), + writer: writer + ) + _ = scope.process( + command: RUMCommandMock(time: currentTime.addingTimeInterval(5), isUserInteraction: true), + context: .mockAny(), + writer: writer + ) + + XCTAssertEqual(scope.activeSession?.isSampled, true) + } + func testGivenStoppedSession_whenUserActionEvent_itStartsANewSession() throws { // Given let currentTime = Date() diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift new file mode 100644 index 0000000000..f0fa7ec75b --- /dev/null +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMDrawnConfigurationTests.swift @@ -0,0 +1,391 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import XCTest +import DatadogInternal +@testable import DatadogRUM +@testable import TestUtilities + +class RUMDrawnConfigurationTests: XCTestCase { + let writer = FileWriterMock() + + /// Owned by the test because `RUMSessionScope` holds its parent `unowned`: the mock's default + /// parent is a temporary, and reading through it after it is released traps. + private let parent = RUMContextProviderMock() + + private func context(rates: RemoteSamplingRates?) -> DatadogContext { + var context: DatadogContext = .mockAny() + context.set(additionalContext: rates) + return context + } + + // MARK: - Configuration URL + + func testConfigurationURLBuildsFromSiteIntake() throws { + let context: DatadogContext = .mockWith( + clientToken: "token-1", + env: "prod", + version: "1.2.3", + sdkVersion: "2.3.4" + ) + + let url = try XCTUnwrap(remoteSamplingConfigurationURL(customEndpoint: nil, context: context)) + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + XCTAssertTrue(components.path.hasSuffix("/api/v2/rum/config")) + let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value) }) + XCTAssertEqual(query["client_token"], "token-1") + XCTAssertEqual(query["sdk"], "ios") + XCTAssertEqual(query["sdk_version"], "2.3.4") + XCTAssertEqual(query["env"], "prod") + XCTAssertEqual(query["app_version"], "1.2.3") + } + + func testConfigurationURLBuildsNextToCustomEndpoint() throws { + let url = try XCTUnwrap(remoteSamplingConfigurationURL( + customEndpoint: URL(string: "https://custom.example.com/intake")!, + context: .mockAny() + )) + + XCTAssertEqual(url.host, "custom.example.com") + XCTAssertTrue(url.path.hasSuffix("/intake/config"), "the configuration lives beside a custom intake") + } + + // MARK: - Fetch trigger (session-driven, opt-in) + + func testWhenRemoteConfigurationEnabled_sessionCreationPublishesSource() { + let featureScope = FeatureScopeMock() + + _ = RUMSessionScope.mockWith( + context: context(rates: nil), + dependencies: .mockWith( + featureScope: featureScope, + remoteConfigurationEnabled: true, + customEndpoint: nil + ) + ) + + let source = featureScope.contextMock.additionalContext(ofType: RemoteSamplingSource.self) + XCTAssertNotNil(source, "every session creation is one opportunity to fetch") + } + + func testWhenRemoteConfigurationDisabled_sessionCreationPublishesNothing() { + let featureScope = FeatureScopeMock() + + _ = RUMSessionScope.mockWith( + context: context(rates: nil), + dependencies: .mockWith( + featureScope: featureScope, + remoteConfigurationEnabled: false + ) + ) + + XCTAssertNil( + featureScope.contextMock.additionalContext(ofType: RemoteSamplingSource.self), + "opt-out means zero extra requests" + ) + } + + // MARK: - Session draw + + func testSessionDrawUsesRemoteRateWhenSet() { + let rejected = RUMSessionScope.mockWith( + context: context(rates: RemoteSamplingRates(sessionSampleRate: 0)), + dependencies: .mockWith(sessionSampler: .mockKeepAll()) + ) + XCTAssertFalse(rejected.isSampled, "the console's 0% wins over the init 100%") + XCTAssertEqual(rejected.sessionUUID, .nullUUID) + + let kept = RUMSessionScope.mockWith( + context: context(rates: RemoteSamplingRates(sessionSampleRate: 100)), + dependencies: .mockWith(sessionSampler: .mockRejectAll()) + ) + XCTAssertTrue(kept.isSampled, "the console's 100% wins over the init 0%") + XCTAssertNotEqual(kept.sessionUUID, .nullUUID) + } + + func testSessionDrawKeepsInitRateWhenConsoleSetNone() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: nil, version: 42)), + dependencies: .mockWith(sessionSampler: .mockRejectAll()) + ) + XCTAssertFalse(scope.isSampled, "absent means keep the init value, absent is not zero") + } + + func testDrawRecordCarriesSessionConfiguration() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates( + sessionSampleRate: 20, + version: 42, + custom: #"{"a":1}"# + )), + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)) + ) + + let drawn = scope.drawnConfiguration + XCTAssertEqual(drawn?.sessionSampleRate, 20) + XCTAssertEqual(drawn?.version, 42) + XCTAssertEqual(scope.context.drawnConfiguration, drawn, "the record flows down the scope chain") + } + + func testDrawRecordResolvesInitRateFallback() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: nil)), + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)) + ) + + XCTAssertEqual(scope.drawnConfiguration?.sessionSampleRate, 80, "no console rate means the init rate is the drawn rate") + XCTAssertEqual(scope.drawnConfiguration?.version, 0) + } + + func testNoRemoteRatesMeansNoDrawRecord() { + let scope = RUMSessionScope.mockWith( + context: context(rates: nil), + dependencies: .mockWith(sessionSampler: .mockKeepAll()) + ) + XCTAssertNil(scope.drawnConfiguration) + } + + // MARK: - The synchronous read + + func testSessionDrawReadsRatesSynchronouslyNotFromTheContext() { + // The context is empty, exactly as it is for the first session of a launch: the core has + // loaded the stored snapshot but the write onto the context queue has not landed yet. The + // draw must still see the stored rate. + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: nil), + dependencies: .mockWith( + sessionSampler: .mockKeepAll(), + remoteSamplingRates: { RemoteSamplingRates(sessionSampleRate: 0, version: 3) } + ) + ) + + XCTAssertFalse(scope.isSampled, "the stored 0% must decide the first session, not the init 100%") + XCTAssertEqual(scope.drawnConfiguration?.version, 3) + } + + func testSessionDrawKeepsInitRateWhenNothingWasStored() { + // Negative control for the test above: with nothing stored and nothing on the context, the + // init rate decides — so the assertion above really is reading the stored value. + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: nil), + dependencies: .mockWith(sessionSampler: .mockKeepAll(), remoteSamplingRates: { nil }) + ) + + XCTAssertTrue(scope.isSampled) + XCTAssertNil(scope.drawnConfiguration) + } + + // MARK: - beforeSampling + + func testBeforeSamplingHasTheLastWordOverTheConsole() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: 0, version: 7)), + dependencies: .mockWith(sessionSampler: .mockRejectAll(), beforeSampling: { _ in 100 }) + ) + + XCTAssertTrue(scope.isSampled, "an allow-list must be able to keep a visitor the console's rate would drop") + XCTAssertEqual(scope.drawnConfiguration?.sessionSampleRate, 100, "events report the rate the draw actually used") + } + + func testBeforeSamplingSeesTheRateAndCustomThatWouldApply() { + var seen: BeforeSamplingContext? + _ = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: 42, version: 7, custom: #"{"vip":["u-1"]}"#)), + dependencies: .mockWith( + sessionSampler: .mockKeepAll(), + beforeSampling: { ctx in + seen = ctx + return nil + } + ) + ) + + XCTAssertEqual(seen?.sessionSampleRate, 42, "the hook is consulted after the console, so it sees the console's rate") + XCTAssertEqual(seen?.custom?["vip"] as? [String], ["u-1"]) + } + + func testBeforeSamplingKeepsTheIncomingRateWhenItReturnsNothingOrNonsense() { + for hook: BeforeSamplingCallback in [{ _ in nil }, { _ in 150 }, { _ in -1 }] { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: 0, version: 7)), + dependencies: .mockWith(sessionSampler: .mockKeepAll(), beforeSampling: hook) + ) + XCTAssertFalse(scope.isSampled, "an unusable answer leaves the console's rate alone") + } + } + + // MARK: - Forced session + + func testForcedSessionSkipsTheDrawEntirely() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: RemoteSamplingRates(sessionSampleRate: 0, version: 7)), + dependencies: .mockWith(sessionSampler: .mockRejectAll()), + isForced: true + ) + + XCTAssertTrue(scope.isSampled, "a coin flip could still say no, and the app has said it must not") + XCTAssertTrue(scope.context.sessionForced, "Session Replay reads this so a forced session comes out with replay") + } + + func testUnforcedSessionDoesNotClaimToBeForced() { + let scope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: nil), + dependencies: .mockWith(sessionSampler: .mockKeepAll()) + ) + + XCTAssertFalse(scope.context.sessionForced) + } + + // MARK: - View event + + func testViewEventReportsDrawnConfiguration() throws { + let rates = RemoteSamplingRates( + sessionSampleRate: 20, + version: 42 + ) + let sessionScope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: rates), + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)) + ) + // ARC may release `sessionScope` at its last strong use, which is the line below — the view + // scope's reference to it is unowned and would then dangle. + defer { withExtendedLifetime(sessionScope) {} } + let scope = RUMViewScope( + isInitialView: true, + parent: sessionScope, + dependencies: .mockAny(), + identity: .mockViewIdentifier(), + path: "UIViewController", + name: "ViewName", + customTimings: [:], + startTime: .mockAny(), + serverTimeOffset: .zero, + interactionToNextViewMetric: nil, + viewIndexInSession: 0 + ) + + _ = scope.process(command: RUMCommandMock(time: .mockAny()), context: .mockAny(), writer: writer) + + let event = try XCTUnwrap(writer.events(ofType: RUMViewEvent.self).first) + XCTAssertEqual(event.dd.configuration?.sessionSampleRate, 20, "the drawn rate is reported, not the init one") + XCTAssertEqual(event.dd.configuration?.rcVersion, 42, "the version the session was drawn with") + } + + func testViewEventWithoutRemoteConfigurationReportsInitValuesAndNoVersion() throws { + let sessionScope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: nil), + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)) + ) + // ARC may release `sessionScope` at its last strong use, which is the line below — the view + // scope's reference to it is unowned and would then dangle. + defer { withExtendedLifetime(sessionScope) {} } + let scope = RUMViewScope( + isInitialView: true, + parent: sessionScope, + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)), + identity: .mockViewIdentifier(), + path: "UIViewController", + name: "ViewName", + customTimings: [:], + startTime: .mockAny(), + serverTimeOffset: .zero, + interactionToNextViewMetric: nil, + viewIndexInSession: 0 + ) + + _ = scope.process(command: RUMCommandMock(time: .mockAny()), context: .mockAny(), writer: writer) + + let event = try XCTUnwrap(writer.events(ofType: RUMViewEvent.self).first) + XCTAssertEqual(event.dd.configuration?.sessionSampleRate, 80) + XCTAssertNil(event.dd.configuration?.rcVersion, "no remote configuration, no rc_version") + } + + func testEveryEventTypeReportsTheDrawnRateNotTheInitOne() throws { + // The view event is not the only one carrying the rate: the backend extrapolates from + // whatever each event reports, and a customer reading the explorer sees it on every row. + // An error that says the session was drawn at the init rate is simply a wrong number. + let rates = RemoteSamplingRates(sessionSampleRate: 20, version: 42) + let sessionScope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: rates), + dependencies: .mockWith(sessionSampler: Sampler(samplingRate: 80)) + ) + defer { withExtendedLifetime(sessionScope) {} } + let scope = RUMViewScope( + isInitialView: true, + parent: sessionScope, + dependencies: .mockAny(), + identity: .mockViewIdentifier(), + path: "UIViewController", + name: "ViewName", + customTimings: [:], + startTime: .mockAny(), + serverTimeOffset: .zero, + interactionToNextViewMetric: nil, + viewIndexInSession: 0 + ) + + _ = scope.process(command: RUMCommandMock(time: .mockAny()), context: .mockAny(), writer: writer) + _ = scope.process( + command: RUMAddCurrentViewErrorCommand.mockWithErrorMessage(time: .mockAny(), message: .mockAny()), + context: .mockAny(), + writer: writer + ) + + let errorEvent = try XCTUnwrap(writer.events(ofType: RUMErrorEvent.self).first) + XCTAssertEqual(errorEvent.dd.configuration?.sessionSampleRate, 20, "an error reports the drawn rate too") + } + + func testBeforeSamplingAloneIsRecordedEvenWithoutRemoteConfiguration() throws { + // The hook applies whether or not the console publishes anything. Without a record, a + // session drawn at the hook's rate would report the init rate on every event — the rate + // that did not decide it. + let sessionScope = RUMSessionScope.mockWith( + parent: parent, + context: context(rates: nil), + dependencies: .mockWith( + sessionSampler: Sampler(samplingRate: 80), + remoteSamplingRates: { nil }, + beforeSampling: { _ in 100 } + ) + ) + + XCTAssertEqual(sessionScope.drawnConfiguration?.sessionSampleRate, 100) + XCTAssertEqual(sessionScope.drawnConfiguration?.version, 0, "no console configuration was in effect") + } + + // MARK: - Encoding (fork patch) + + func testConfigurationEncodesRCVersion() throws { + let configuration = RUMViewEvent.DD.Configuration(rcVersion: 42, sessionSampleRate: 100) + + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(configuration)) as? [String: Any] + ) + XCTAssertEqual(json["rc_version"] as? Int, 42) + XCTAssertEqual(json["session_sample_rate"] as? Int, 100) + + // And it is absent when there is no remote configuration: + let initOnly = RUMViewEvent.DD.Configuration(sessionSampleRate: 100) + let initJSON = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(initOnly)) as? [String: Any] + ) + XCTAssertNil(initJSON["rc_version"]) + } +} diff --git a/DatadogSessionReplay/Sources/Recorder/RecordingCoordinator.swift b/DatadogSessionReplay/Sources/Recorder/RecordingCoordinator.swift index 8d8e0d6df2..0010f0e570 100644 --- a/DatadogSessionReplay/Sources/Recorder/RecordingCoordinator.swift +++ b/DatadogSessionReplay/Sources/Recorder/RecordingCoordinator.swift @@ -99,7 +99,10 @@ internal class RecordingCoordinator { private func onRUMContextChanged(rumContext: RUMCoreContext?) { if currentRUMContext?.sessionID != rumContext?.sessionID || currentRUMContext == nil { - isSampled = sampler.sample() + // FLASHCAT FORK - a session the host application forced skips replay's own draw: + // forcing exists to debug one visitor, and a replay-less recording of them is not the + // thing that was asked for. + isSampled = rumContext?.sessionForced == true || sampler.sample() } currentRUMContext = rumContext diff --git a/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift b/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift index 18110713d1..93de27c5ee 100644 --- a/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift +++ b/TestUtilities/Sources/Mocks/DatadogCore/HTTPClientMock.swift @@ -14,6 +14,8 @@ public class HTTPClientMock: HTTPClient { private var requests: [URLRequest] = [] /// Closure providing the result for each request. private let result: (URLRequest) -> Result + /// Body handed back by `fetch(request:)`, for requests that read a response rather than upload. + public var fetchBody = Data() /// Initializes the mock client with a result closure. /// - Parameter result: Closure providing the completion result for each incoming request (default is a successful HTTP response with `202` code). @@ -48,6 +50,12 @@ public class HTTPClientMock: HTTPClient { } } + public func fetch(request: URLRequest, completion: @escaping (Result<(response: HTTPURLResponse, body: Data), Error>) -> Void) { + send(request: request, delegate: nil) { result in + completion(result.map { (response: $0, body: self.fetchBody) }) + } + } + // MARK: - Tracked requests retrieval /// Retrieves the tracked requests. diff --git a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift index 41ecadccd7..e62c499827 100644 --- a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift +++ b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift @@ -1071,7 +1071,11 @@ extension RUMScopeDependencies { interactionToNextViewMetricFactory: @escaping () -> INVMetricTracking = { INVMetric(predicate: TimeBasedINVActionPredicate()) }, - sessionType: RUMSessionType? = nil + sessionType: RUMSessionType? = nil, + remoteConfigurationEnabled: Bool = false, + customEndpoint: URL? = nil, + remoteSamplingRates: @escaping () -> RemoteSamplingRates? = { nil }, + beforeSampling: BeforeSamplingCallback? = nil ) -> RUMScopeDependencies { return RUMScopeDependencies( featureScope: featureScope, @@ -1100,7 +1104,11 @@ extension RUMScopeDependencies { watchdogTermination: watchdogTermination, networkSettledMetricFactory: networkSettledMetricFactory, interactionToNextViewMetricFactory: interactionToNextViewMetricFactory, - sessionType: sessionType + sessionType: sessionType, + remoteConfigurationEnabled: remoteConfigurationEnabled, + customEndpoint: customEndpoint, + remoteSamplingRates: remoteSamplingRates, + beforeSampling: beforeSampling ) } @@ -1185,7 +1193,8 @@ extension RUMSessionScope { context: DatadogContext = .mockAny(), dependencies: RUMScopeDependencies = .mockAny(), applicationState: RUMApplicationState = .mockAny(), - hasReplay: Bool? = .mockAny() + hasReplay: Bool? = .mockAny(), + isForced: Bool = false ) -> RUMSessionScope { return RUMSessionScope( isInitialSession: isInitialSession, @@ -1194,7 +1203,8 @@ extension RUMSessionScope { startPrecondition: startPrecondition, context: context, dependencies: dependencies, - applicationState: applicationState + applicationState: applicationState, + isForced: isForced ) } // swiftlint:enable function_default_parameter_at_end