Skip to content

Commit 9b6b813

Browse files
feat: add user control for assignment event tracking
1 parent fb1ae4b commit 9b6b813

File tree

4 files changed

+124
-146
lines changed

4 files changed

+124
-146
lines changed

Sources/Experiment/ExperimentClient.swift

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient {
4747
internal let flags: LoadStoreCache<EvaluationFlag>
4848
private let flagsStorageQueue = DispatchQueue(label: "com.amplitude.experiment.VariantsStorageQueue", attributes: .concurrent)
4949

50-
internal let fetchOptions: LoadStoreCache<FetchOptions>
51-
private let fetchOptionsStorageQueue = DispatchQueue(label: "com.amplitude.experiment.FetchOptionsStorageQueue", attributes: .concurrent)
50+
public let trackingOption: LoadStoreCache<String>
51+
private let trackingOptionStorageQueue = DispatchQueue(label: "com.amplitude.experiment.TrackingOptionStorageQueue", attributes: .concurrent)
5252

5353
internal let config: ExperimentConfig
5454
private let engine = EvaluationEngine()
@@ -101,8 +101,8 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient {
101101
self.flags = getFlagStorage(apiKey: self.apiKey, instanceName: self.config.instanceName, storage: storage)
102102
self.flags.load()
103103
self.flags.mergeInitialFlagsWithStorage(config.initialFlags)
104-
self.fetchOptions = getFetchOptionsStorage(apiKey: self.apiKey, instanceName: self.config.instanceName, storage: storage)
105-
self.fetchOptions.load()
104+
self.trackingOption = getTrackingOptionStorage(apiKey: self.apiKey, instanceName: self.config.instanceName, storage: storage)
105+
self.trackingOption.load()
106106
}
107107

108108
public func start(_ user: ExperimentUser? = nil, completion: ((Error?) -> Void)? = nil) -> Void {
@@ -513,10 +513,10 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient {
513513
request.setValue(flagKeysB64EncodedUrl, forHTTPHeaderField: "X-Amp-Exp-Flag-Keys")
514514
}
515515

516-
// Add tracking option from stored fetch options or from current options
517-
let trackingOption = options?.trackingOption ?? fetchOptionsStorageQueue.sync { fetchOptions.get(key: "default")?.trackingOption }
518-
if let trackingOption = trackingOption {
519-
request.setValue(trackingOption, forHTTPHeaderField: "X-Amp-Exp-Tracking-Option")
516+
// Add tracking option from current options or from stored setting
517+
let trackingOptionValue = options?.trackingOption ?? trackingOptionStorageQueue.sync { trackingOption.get(key: "default") }
518+
if let trackingOptionValue = trackingOptionValue {
519+
request.setValue(trackingOptionValue, forHTTPHeaderField: "X-Amp-Exp-Track")
520520
}
521521
request.timeoutInterval = Double(timeoutMillis) / 1000.0
522522

@@ -705,10 +705,9 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient {
705705

706706
public func setTrackAssignmentEvent(_ track: Bool) {
707707
let trackingOption = track ? "track" : "no-track"
708-
let fetchOptions = FetchOptions(nil, trackingOption: trackingOption)
709-
fetchOptionsStorageQueue.async(flags: .barrier) {
710-
self.fetchOptions.put(key: "default", value: fetchOptions)
711-
self.fetchOptions.store()
708+
trackingOptionStorageQueue.sync(flags: .barrier) {
709+
self.trackingOption.put(key: "default", value: trackingOption)
710+
self.trackingOption.store(async: false)
712711
}
713712
}
714713
}

Sources/Experiment/FetchOptions.swift

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,12 @@
77

88
import Foundation
99

10-
@objc public class FetchOptions : NSObject, Codable {
10+
@objc public class FetchOptions : NSObject {
1111
@objc public let flagKeys: [String]?
1212
@objc public let trackingOption: String?
1313

1414
@objc public init(_ flagKeys: [String]? = nil, trackingOption: String? = nil) {
1515
self.flagKeys = flagKeys
1616
self.trackingOption = trackingOption
1717
}
18-
19-
private enum CodingKeys: String, CodingKey {
20-
case flagKeys
21-
case trackingOption
22-
}
23-
24-
public required init(from decoder: Decoder) throws {
25-
let container = try decoder.container(keyedBy: CodingKeys.self)
26-
flagKeys = try container.decodeIfPresent([String].self, forKey: .flagKeys)
27-
trackingOption = try container.decodeIfPresent(String.self, forKey: .trackingOption)
28-
}
29-
30-
public func encode(to encoder: Encoder) throws {
31-
var container = encoder.container(keyedBy: CodingKeys.self)
32-
try container.encodeIfPresent(flagKeys, forKey: .flagKeys)
33-
try container.encodeIfPresent(trackingOption, forKey: .trackingOption)
34-
}
3518
}

Sources/Experiment/Storage.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ internal func getFlagStorage(apiKey: String, instanceName: String, storage: Stor
1717
return LoadStoreCache(namespace: namespace, storage: storage)
1818
}
1919

20-
internal func getFetchOptionsStorage(apiKey: String, instanceName: String, storage: Storage) -> LoadStoreCache<FetchOptions> {
21-
let namespace = "com.amplituide.experiment.fetchOptions.\(instanceName).\(apiKey.suffix(6))"
20+
internal func getTrackingOptionStorage(apiKey: String, instanceName: String, storage: Storage) -> LoadStoreCache<String> {
21+
let namespace = "com.amplituide.experiment.trackingOption.\(instanceName).\(apiKey.suffix(6))"
2222
return LoadStoreCache(namespace: namespace, storage: storage)
2323
}
2424

Tests/ExperimentTests/ExperimentClientTests.swift

Lines changed: 110 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,107 +1255,8 @@ class ExperimentClientTests: XCTestCase {
12551255
let client = DefaultExperimentClient(apiKey: "", config: config, storage: InMemoryStorage())
12561256
XCTAssertEqual(900000, client.config.flagConfigPollingIntervalMillis)
12571257
}
1258-
}
1259-
1260-
class TestAnalyticsProvider : ExperimentAnalyticsProvider {
1261-
var didExposureGetTracked = false
1262-
var didUserPropertyGetSet = false
1263-
var didUserPropertyGetUnset = false
1264-
let _track: (ExperimentAnalyticsEvent) -> ()
1265-
let _setUserProperty: (ExperimentAnalyticsEvent) -> ()
1266-
let _unsetUserProperty: (ExperimentAnalyticsEvent) -> ()
1267-
init(track: @escaping (ExperimentAnalyticsEvent) -> (), setUserProperty: @escaping (ExperimentAnalyticsEvent) -> (), unsetUserProperty: @escaping (ExperimentAnalyticsEvent) -> ()) {
1268-
self._track = track
1269-
self._setUserProperty = setUserProperty
1270-
self._unsetUserProperty = unsetUserProperty
1271-
}
1272-
func track(_ event: ExperimentAnalyticsEvent) {
1273-
_track(event)
1274-
didExposureGetTracked = true
1275-
}
1276-
func setUserProperty(_ event: ExperimentAnalyticsEvent) {
1277-
_setUserProperty(event)
1278-
didUserPropertyGetSet = true
1279-
}
1280-
func unsetUserProperty(_ event: ExperimentAnalyticsEvent) {
1281-
_unsetUserProperty(event)
1282-
didUserPropertyGetUnset = true
1283-
}
1284-
}
12851258

1286-
class TestUserProvider : ExperimentUserProvider {
1287-
func getUser() -> ExperimentUser {
1288-
return ExperimentUserBuilder()
1289-
.deviceId("")
1290-
.version("version2")
1291-
.language("")
1292-
.build()
1293-
}
1294-
}
1295-
1296-
class InMemoryStorage: Storage {
1297-
private var store: [String: Data] = [:]
1298-
func get(key: String) -> Data? {
1299-
return store[key]
1300-
}
1301-
1302-
func put(key: String, value: Data) {
1303-
store[key] = value
1304-
}
1305-
1306-
func delete(key: String) {
1307-
store.removeValue(forKey: key)
1308-
}
1309-
}
1310-
1311-
extension DefaultExperimentClient {
1312-
func startBlocking(user: ExperimentUser) {
1313-
let s = DispatchSemaphore(value: 0)
1314-
start(user) { error in
1315-
if let error = error {
1316-
XCTFail(error.localizedDescription)
1317-
}
1318-
s.signal()
1319-
}
1320-
switch s.wait(timeout: .now() + .seconds(20)) {
1321-
case .timedOut: XCTFail("start request timed out")
1322-
case .success: return
1323-
}
1324-
}
1325-
func startBlockingThrows(user: ExperimentUser) throws {
1326-
let s = DispatchSemaphore(value: 0)
1327-
var err: Error?
1328-
start(user) { error in
1329-
if let error = error {
1330-
err = error
1331-
}
1332-
s.signal()
1333-
}
1334-
switch s.wait(timeout: .now() + .seconds(20)) {
1335-
case .timedOut: XCTFail("start request timed out")
1336-
case .success:
1337-
if let error = err {
1338-
throw error
1339-
}
1340-
}
1341-
}
1342-
func fetchBlocking(user: ExperimentUser, isTestRetry: Bool = false) {
1343-
let s = DispatchSemaphore(value: 0)
1344-
fetch(user: user) { _, error in
1345-
if let error = error {
1346-
if (!isTestRetry) {
1347-
XCTFail(error.localizedDescription)
1348-
}
1349-
}
1350-
s.signal()
1351-
}
1352-
switch s.wait(timeout: .now() + .seconds(20)) {
1353-
case .timedOut: XCTFail("start request timed out")
1354-
case .success: return
1355-
}
1356-
}
1357-
1358-
func testSetTrackAssignmentEvent() {
1259+
func testSetTrackAssignmentEvent() {
13591260
let client = DefaultExperimentClient(
13601261
apiKey: API_KEY,
13611262
config: ExperimentConfigBuilder()
@@ -1368,17 +1269,15 @@ extension DefaultExperimentClient {
13681269
client.setTrackAssignmentEvent(true)
13691270

13701271
// Verify the setting was stored
1371-
let storedOptions = client.fetchOptions.get(key: "default")
1372-
XCTAssertNotNil(storedOptions)
1373-
XCTAssertEqual(storedOptions?.trackingOption, "track")
1272+
let storedOption = client.trackingOption.get(key: "default")
1273+
XCTAssertEqual(storedOption, "track")
13741274

13751275
// Test setting track assignment event to false
13761276
client.setTrackAssignmentEvent(false)
13771277

13781278
// Verify the setting was updated
1379-
let updatedOptions = client.fetchOptions.get(key: "default")
1380-
XCTAssertNotNil(updatedOptions)
1381-
XCTAssertEqual(updatedOptions?.trackingOption, "no-track")
1279+
let updatedOption = client.trackingOption.get(key: "default")
1280+
XCTAssertEqual(updatedOption, "no-track")
13821281
}
13831282

13841283
func testSetTrackAssignmentEventPersistence() {
@@ -1404,9 +1303,8 @@ extension DefaultExperimentClient {
14041303
)
14051304

14061305
// Verify the setting was persisted and loaded by the second client
1407-
let storedOptions = client2.fetchOptions.get(key: "default")
1408-
XCTAssertNotNil(storedOptions)
1409-
XCTAssertEqual(storedOptions?.trackingOption, "track")
1306+
let storedOption = client2.trackingOption.get(key: "default")
1307+
XCTAssertEqual(storedOption, "track")
14101308
}
14111309

14121310
func testSetTrackAssignmentEventMultipleCalls() {
@@ -1423,9 +1321,8 @@ extension DefaultExperimentClient {
14231321
client.setTrackAssignmentEvent(false)
14241322

14251323
// Verify the latest setting is used
1426-
let storedOptions = client.fetchOptions.get(key: "default")
1427-
XCTAssertNotNil(storedOptions)
1428-
XCTAssertEqual(storedOptions?.trackingOption, "no-track")
1324+
let storedOption = client.trackingOption.get(key: "default")
1325+
XCTAssertEqual(storedOption, "no-track")
14291326
}
14301327

14311328
func testSetTrackAssignmentEventWithFetchOptions() {
@@ -1458,8 +1355,8 @@ extension DefaultExperimentClient {
14581355
)
14591356

14601357
// Verify default behavior when setTrackAssignmentEvent is not called
1461-
let storedOptions = client.fetchOptions.get(key: "default")
1462-
XCTAssertNil(storedOptions)
1358+
let storedOption = client.trackingOption.get(key: "default")
1359+
XCTAssertNil(storedOption)
14631360
}
14641361

14651362
func testSetTrackAssignmentEventWithFetch() {
@@ -1484,3 +1381,102 @@ extension DefaultExperimentClient {
14841381
s.wait()
14851382
}
14861383
}
1384+
1385+
class TestAnalyticsProvider : ExperimentAnalyticsProvider {
1386+
var didExposureGetTracked = false
1387+
var didUserPropertyGetSet = false
1388+
var didUserPropertyGetUnset = false
1389+
let _track: (ExperimentAnalyticsEvent) -> ()
1390+
let _setUserProperty: (ExperimentAnalyticsEvent) -> ()
1391+
let _unsetUserProperty: (ExperimentAnalyticsEvent) -> ()
1392+
init(track: @escaping (ExperimentAnalyticsEvent) -> (), setUserProperty: @escaping (ExperimentAnalyticsEvent) -> (), unsetUserProperty: @escaping (ExperimentAnalyticsEvent) -> ()) {
1393+
self._track = track
1394+
self._setUserProperty = setUserProperty
1395+
self._unsetUserProperty = unsetUserProperty
1396+
}
1397+
func track(_ event: ExperimentAnalyticsEvent) {
1398+
_track(event)
1399+
didExposureGetTracked = true
1400+
}
1401+
func setUserProperty(_ event: ExperimentAnalyticsEvent) {
1402+
_setUserProperty(event)
1403+
didUserPropertyGetSet = true
1404+
}
1405+
func unsetUserProperty(_ event: ExperimentAnalyticsEvent) {
1406+
_unsetUserProperty(event)
1407+
didUserPropertyGetUnset = true
1408+
}
1409+
}
1410+
1411+
class TestUserProvider : ExperimentUserProvider {
1412+
func getUser() -> ExperimentUser {
1413+
return ExperimentUserBuilder()
1414+
.deviceId("")
1415+
.version("version2")
1416+
.language("")
1417+
.build()
1418+
}
1419+
}
1420+
1421+
class InMemoryStorage: Storage {
1422+
private var store: [String: Data] = [:]
1423+
func get(key: String) -> Data? {
1424+
return store[key]
1425+
}
1426+
1427+
func put(key: String, value: Data) {
1428+
store[key] = value
1429+
}
1430+
1431+
func delete(key: String) {
1432+
store.removeValue(forKey: key)
1433+
}
1434+
}
1435+
1436+
extension DefaultExperimentClient {
1437+
func startBlocking(user: ExperimentUser) {
1438+
let s = DispatchSemaphore(value: 0)
1439+
start(user) { error in
1440+
if let error = error {
1441+
XCTFail(error.localizedDescription)
1442+
}
1443+
s.signal()
1444+
}
1445+
switch s.wait(timeout: .now() + .seconds(20)) {
1446+
case .timedOut: XCTFail("start request timed out")
1447+
case .success: return
1448+
}
1449+
}
1450+
func startBlockingThrows(user: ExperimentUser) throws {
1451+
let s = DispatchSemaphore(value: 0)
1452+
var err: Error?
1453+
start(user) { error in
1454+
if let error = error {
1455+
err = error
1456+
}
1457+
s.signal()
1458+
}
1459+
switch s.wait(timeout: .now() + .seconds(20)) {
1460+
case .timedOut: XCTFail("start request timed out")
1461+
case .success:
1462+
if let error = err {
1463+
throw error
1464+
}
1465+
}
1466+
}
1467+
func fetchBlocking(user: ExperimentUser, isTestRetry: Bool = false) {
1468+
let s = DispatchSemaphore(value: 0)
1469+
fetch(user: user) { _, error in
1470+
if let error = error {
1471+
if (!isTestRetry) {
1472+
XCTFail(error.localizedDescription)
1473+
}
1474+
}
1475+
s.signal()
1476+
}
1477+
switch s.wait(timeout: .now() + .seconds(20)) {
1478+
case .timedOut: XCTFail("start request timed out")
1479+
case .success: return
1480+
}
1481+
}
1482+
}

0 commit comments

Comments
 (0)