From 0d2a9bcf909cc54ddf02d5c890c6913d511cb886 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:00:51 +0900 Subject: [PATCH] ab testing --- CHANGELOG.md | 3 + .../TestingRelated/ModuleRemoteConfigTests.cs | 162 ++++++++++++++++++ countlyCommon/countlyCommon/CountlyBase.cs | 2 +- .../Entities/CountlyConfigBase.cs | 18 ++ .../Modules/ModuleRemoteConfig.cs | 54 +++++- 5 files changed, 236 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16306af..9cd6074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ * "DownloadKeys" for fetching RC values from server * "GetValues" for accessing all RC values * "GetValue" for accessing the given RC value + * "EnrollIntoABTestsForKeys" for enrolling the user into A/B tests for the given keys + * "ExitABTestsForKeys" for removing the user from A/B tests for the given keys (no keys exits all) +* Added configuration option "DisableAutoEnrollInABTesting" to stop automatically opting users into A/B tests during Remote Config download (auto opt-in is enabled by default). * Added configuration option "EnableRemoteConfigAutomaticTriggers" to automatically download remote config values: * After initialization finishes. * RemoteConfig consent is given. diff --git a/countlyCommon/TestingRelated/ModuleRemoteConfigTests.cs b/countlyCommon/TestingRelated/ModuleRemoteConfigTests.cs index 96502e5..9c634bf 100644 --- a/countlyCommon/TestingRelated/ModuleRemoteConfigTests.cs +++ b/countlyCommon/TestingRelated/ModuleRemoteConfigTests.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; +using System.Web; using CountlySDK; using CountlySDK.CountlyCommon; +using CountlySDK.CountlyCommon.Entities; using CountlySDK.Entities; using Newtonsoft.Json; using Xunit; @@ -132,6 +135,165 @@ public void DownloadKeys_Invalid() } } + [Fact] + /// + /// By default (auto opt-in enabled), a Remote Config download request includes oi=1. + /// + public void DownloadKeys_AutoEnrollDefault_SendsOptIn() + { + string body = null; + MockHttpServer server = new MockHttpServer((b) => { + if (b.Contains("method=rc")) { body = b; return "{}"; } + return null; + }); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + + Countly.Instance.Init(cc).Wait(); + Countly.Instance.RemoteConfig().DownloadKeys().Wait(); + + Assert.Contains("oi=1", body); + server.Dispose(); + } + + [Fact] + /// + /// When auto opt-in is disabled via config, the Remote Config download request omits oi. + /// + public void DownloadKeys_AutoEnrollDisabled_OmitsOptIn() + { + string body = null; + MockHttpServer server = new MockHttpServer((b) => { + if (b.Contains("method=rc")) { body = b; return "{}"; } + return null; + }); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + cc.DisableAutoEnrollInABTesting(); + + Countly.Instance.Init(cc).Wait(); + Countly.Instance.RemoteConfig().DownloadKeys().Wait(); + + Assert.DoesNotContain("oi=1", body); + server.Dispose(); + } + + [Fact] + /// + /// Enrolling into A/B tests queues a method=ab request carrying the JSON-encoded keys. + /// + public void EnrollIntoABTestsForKeys_QueuesAbRequest() + { + MockHttpServer server = new MockHttpServer(); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + + Countly.Instance.Init(cc).Wait(); + Countly.Instance.deferUpload = true; + Countly.Instance.StoredRequests.Clear(); + + Countly.Instance.RemoteConfig().EnrollIntoABTestsForKeys(new List { "a", "b" }).Wait(); + + Assert.Single(Countly.Instance.StoredRequests); + StoredRequest sr = Countly.Instance.StoredRequests.Dequeue(); + NameValueCollection q = HttpUtility.ParseQueryString(sr.Request); + Assert.Equal("ab", q.Get("method")); + Assert.Equal("[\"a\",\"b\"]", q.Get("keys")); + server.Dispose(); + } + + [Fact] + /// + /// Enrolling with null or empty keys is a no-op (nothing is queued). + /// + public void EnrollIntoABTestsForKeys_NoKeys_QueuesNothing() + { + MockHttpServer server = new MockHttpServer(); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + + Countly.Instance.Init(cc).Wait(); + Countly.Instance.deferUpload = true; + Countly.Instance.StoredRequests.Clear(); + + Countly.Instance.RemoteConfig().EnrollIntoABTestsForKeys(null).Wait(); + Countly.Instance.RemoteConfig().EnrollIntoABTestsForKeys(new List()).Wait(); + + Assert.Empty(Countly.Instance.StoredRequests); + server.Dispose(); + } + + [Fact] + /// + /// Exiting A/B tests with keys queues a method=ab_opt_out request carrying the JSON keys. + /// + public void ExitABTestsForKeys_WithKeys_QueuesOptOutRequest() + { + MockHttpServer server = new MockHttpServer(); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + + Countly.Instance.Init(cc).Wait(); + Countly.Instance.deferUpload = true; + Countly.Instance.StoredRequests.Clear(); + + Countly.Instance.RemoteConfig().ExitABTestsForKeys(new List { "a", "b" }).Wait(); + + Assert.Single(Countly.Instance.StoredRequests); + StoredRequest sr = Countly.Instance.StoredRequests.Dequeue(); + NameValueCollection q = HttpUtility.ParseQueryString(sr.Request); + Assert.Equal("ab_opt_out", q.Get("method")); + Assert.Equal("[\"a\",\"b\"]", q.Get("keys")); + server.Dispose(); + } + + [Fact] + /// + /// Exiting with no keys (exit from ALL tests) queues method=ab_opt_out with no keys param. + /// + public void ExitABTestsForKeys_NoKeys_QueuesOptOutWithoutKeys() + { + MockHttpServer server = new MockHttpServer(); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + + Countly.Instance.Init(cc).Wait(); + Countly.Instance.deferUpload = true; + Countly.Instance.StoredRequests.Clear(); + + Countly.Instance.RemoteConfig().ExitABTestsForKeys().Wait(); + + Assert.Single(Countly.Instance.StoredRequests); + StoredRequest sr = Countly.Instance.StoredRequests.Dequeue(); + NameValueCollection q = HttpUtility.ParseQueryString(sr.Request); + Assert.Equal("ab_opt_out", q.Get("method")); + Assert.Null(q.Get("keys")); + server.Dispose(); + } + + [Fact] + /// + /// When consent is required but Remote Config consent is not granted, enroll/exit are + /// gated by the RemoteConfig() accessor (returns a mock) and queue nothing. + /// + public void EnrollExit_ConsentRequiredNotGranted_QueuesNothing() + { + MockHttpServer server = new MockHttpServer(); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + cc.consentRequired = true; + + Countly.Instance.Init(cc).Wait(); + Countly.Instance.deferUpload = true; + Countly.Instance.StoredRequests.Clear(); + + Countly.Instance.RemoteConfig().EnrollIntoABTestsForKeys(new List { "a" }).Wait(); + Countly.Instance.RemoteConfig().ExitABTestsForKeys(new List { "a" }).Wait(); + + Assert.Empty(Countly.Instance.StoredRequests); + server.Dispose(); + } + private void RemoteConfigDownloadFlow(Action configSetter, Action runnable = null, bool expectDownload = true, int calledTimesExpected = 1) { IDictionary expectedRcValues = new Dictionary() { diff --git a/countlyCommon/countlyCommon/CountlyBase.cs b/countlyCommon/countlyCommon/CountlyBase.cs index 77ce6e7..894a49b 100644 --- a/countlyCommon/countlyCommon/CountlyBase.cs +++ b/countlyCommon/countlyCommon/CountlyBase.cs @@ -1672,7 +1672,7 @@ protected async Task InitBase(CountlyConfig config) await SetConsentInternal(config.givenConsent, ConsentChangedAction.Initialization); } - moduleRemoteConfig = new ModuleRemoteConfig(requestHelper, ServerUrl); + moduleRemoteConfig = new ModuleRemoteConfig(requestHelper, ServerUrl, Configuration.enableABTestingAutoEnroll); moduleFeedback = new ModuleFeedback(requestHelper, ServerUrl); moduleContent = new ModuleContent(requestHelper, ServerUrl); diff --git a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs index ca9f097..b379a08 100644 --- a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs +++ b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs @@ -133,6 +133,12 @@ public int ContentZoneTimerInterval { internal bool autoSendUserDetails = true; internal bool remoteConfigAutomaticDownloadTriggers = false; + // + /// A/B testing auto opt-in during Remote Config fetch (adds oi=1 to the rc request). + /// Enabled by default; disable with DisableAutoEnrollInABTesting(). + /// + internal bool enableABTestingAutoEnroll = true; + // /// Developer-provided SDK Behavior Settings (Server Config) JSON, applied as a /// precedence layer below server-fetched settings. May be a full {v,t,c} envelope @@ -349,6 +355,18 @@ public CountlyConfigBase DisableHealthCheck() return this; } + /// + /// Disables automatically opting the user into A/B tests when Remote Config values are + /// downloaded. Auto opt-in is enabled by default; call this to enroll manually instead + /// (via RemoteConfig().EnrollIntoABTestsForKeys). + /// + /// Config for call chaining + public CountlyConfigBase DisableAutoEnrollInABTesting() + { + enableABTestingAutoEnroll = false; + return this; + } + /// /// Sets a listener that receives every SDK log message and its level. Fires regardless /// of whether console logging is enabled. A throwing listener cannot break the SDK. diff --git a/countlyCommon/countlyCommon/Modules/ModuleRemoteConfig.cs b/countlyCommon/countlyCommon/Modules/ModuleRemoteConfig.cs index f0da24c..9d414b3 100644 --- a/countlyCommon/countlyCommon/Modules/ModuleRemoteConfig.cs +++ b/countlyCommon/countlyCommon/Modules/ModuleRemoteConfig.cs @@ -16,12 +16,13 @@ internal class ModuleRemoteConfig : RemoteConfig private readonly string ServerUrl; private IDictionary rcValues = new Dictionary(); private object RCLock = new object(); - private bool AutoEnrollEnabled = true; + private bool AutoEnrollEnabled; - public ModuleRemoteConfig(RequestHelper requestHelper, string serverUrl) + public ModuleRemoteConfig(RequestHelper requestHelper, string serverUrl, bool autoEnroll) { this.requestHelper = requestHelper; ServerUrl = serverUrl; + AutoEnrollEnabled = autoEnroll; } public async Task DownloadKeys(List keysToInclude = null, List keysToOmit = null) @@ -92,6 +93,31 @@ public RCData GetValue(string key) GetValues().TryGetValue(key, out RCData value); return value; } + + public async Task EnrollIntoABTestsForKeys(List keys) + { + if (keys == null || keys.Count == 0) { + UtilityHelper.CountlyLogging("[ModuleRemoteConfig] EnrollIntoABTestsForKeys, no keys provided, ignoring call", LogLevel.WARNING); + return; + } + + IDictionary abParams = new Dictionary { + { "method", "ab" }, + { "keys", JsonConvert.SerializeObject(keys) } + }; + await Countly.Instance.AddRequest(await requestHelper.BuildRequest(abParams)); + } + + public async Task ExitABTestsForKeys(List keys = null) + { + IDictionary abParams = new Dictionary { + { "method", "ab_opt_out" } + }; + if (keys != null && keys.Count > 0) { + abParams.Add("keys", JsonConvert.SerializeObject(keys)); + } + await Countly.Instance.AddRequest(await requestHelper.BuildRequest(abParams)); + } } internal class MockRemoteConfig : RemoteConfig @@ -114,6 +140,14 @@ IDictionary RemoteConfig.GetValues() { return new Dictionary(); } + + async Task RemoteConfig.EnrollIntoABTestsForKeys(List keys) + { + } + + async Task RemoteConfig.ExitABTestsForKeys(List keys) + { + } } /// @@ -173,5 +207,21 @@ public interface RemoteConfig /// otherwise, null. /// RCData GetValue(string key); + + /// + /// Enrolls the user into A/B tests for the given Remote Config keys (method=ab). + /// A null or empty list is ignored. + /// + /// The Remote Config keys to enroll into. + /// A task that represents the asynchronous enroll operation. + Task EnrollIntoABTestsForKeys(List keys); + + /// + /// Exits the user from A/B tests for the given keys (method=ab_opt_out). + /// Passing null or an empty list exits the user from ALL tests. + /// + /// The keys to exit; null or empty exits all tests. + /// A task that represents the asynchronous exit operation. + Task ExitABTestsForKeys(List keys = null); } } \ No newline at end of file