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