diff --git a/Sources/Data Model/DispatchEvents/BatchEvent.swift b/Sources/Data Model/DispatchEvents/BatchEvent.swift index 9a74a455f..345c7b85b 100644 --- a/Sources/Data Model/DispatchEvents/BatchEvent.swift +++ b/Sources/Data Model/DispatchEvents/BatchEvent.swift @@ -85,6 +85,7 @@ struct DecisionMetadata: Codable, Equatable { let flagKey: String let variationKey: String let enabled: Bool + var cmabUUID: String? enum CodingKeys: String, CodingKey { case ruleType = "rule_type" @@ -92,6 +93,7 @@ struct DecisionMetadata: Codable, Equatable { case flagKey = "flag_key" case variationKey = "variation_key" case enabled = "enabled" + case cmabUUID = "cmab_uuid" } } diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 1fd9343d0..145702f05 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -400,6 +400,7 @@ class DefaultDecisionService: OPTDecisionService { } let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, isAsync: isAsync, options: options) + reasons.merge(flagExpDecision.reasons) if let decision = flagExpDecision.result { @@ -459,7 +460,7 @@ class DefaultDecisionService: OPTDecisionService { let featureDecision = FeatureDecision(experiment: experiment, variation: nil, source: Constants.DecisionSource.featureTest.rawValue) return DecisionResponse(result: featureDecision, reasons: reasons) } else if let variation = result.variation { - let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue) + let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue, cmabUUID: result.cmabUUID) return DecisionResponse(result: featureDecision, reasons: reasons) } } diff --git a/Sources/Implementation/Events/BatchEventBuilder.swift b/Sources/Implementation/Events/BatchEventBuilder.swift index 4dbd0961c..4027b0321 100644 --- a/Sources/Implementation/Events/BatchEventBuilder.swift +++ b/Sources/Implementation/Events/BatchEventBuilder.swift @@ -28,9 +28,10 @@ class BatchEventBuilder { attributes: OptimizelyAttributes?, flagKey: String, ruleType: String, - enabled: Bool) -> Data? { + enabled: Bool, + cmabUUID: String?) -> Data? { - let metaData = DecisionMetadata(ruleType: ruleType, ruleKey: experiment?.key ?? "", flagKey: flagKey, variationKey: variation?.key ?? "", enabled: enabled) + let metaData = DecisionMetadata(ruleType: ruleType, ruleKey: experiment?.key ?? "", flagKey: flagKey, variationKey: variation?.key ?? "", enabled: enabled, cmabUUID: cmabUUID) let decision = Decision(variationID: variation?.id ?? "", campaignID: experiment?.layerId ?? "", diff --git a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift index 06b1b45a0..a75eaf11a 100644 --- a/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift +++ b/Sources/Optimizely+Decide/OptimizelyClient+Decide.swift @@ -314,7 +314,8 @@ extension OptimizelyClient { attributes: attributes, flagKey: feature.key, ruleType: ruleType, - enabled: flagEnabled) + enabled: flagEnabled, + cmabUUID: flagDecision?.cmabUUID) decisionEventDispatched = true } } diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index 092691f3a..8dcf525fd 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -318,7 +318,8 @@ open class OptimizelyClient: NSObject { attributes: attributes, flagKey: "", ruleType: Constants.DecisionSource.experiment.rawValue, - enabled: true) + enabled: true, + cmabUUID: nil) return variation.key } @@ -452,7 +453,8 @@ open class OptimizelyClient: NSObject { attributes: attributes, flagKey: featureKey, ruleType: source, - enabled: featureEnabled) + enabled: featureEnabled, + cmabUUID: pair?.cmabUUID) } sendDecisionNotification(userId: userId, @@ -817,7 +819,8 @@ extension OptimizelyClient { attributes: OptimizelyAttributes? = nil, flagKey: String, ruleType: String, - enabled: Bool) { + enabled: Bool, + cmabUUID: String?) { // non-blocking (event data serialization takes time) eventLock.async { @@ -830,7 +833,8 @@ extension OptimizelyClient { attributes: attributes, flagKey: flagKey, ruleType: ruleType, - enabled: enabled) else { + enabled: enabled, + cmabUUID: cmabUUID) else { self.logger.e(OptimizelyError.eventBuildFailure(DispatchEvent.activateEventKey)) return } diff --git a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Others.swift b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Others.swift index ff8593141..6f8d84f93 100644 --- a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Others.swift +++ b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Others.swift @@ -289,7 +289,7 @@ class OptimizelyClientTests_Others: XCTestCase { // set invalid (infinity) to attribute values, which will cause JSONEncoder.encode exception let attributes = ["testvar": Double.infinity] - optimizely.sendImpressionEvent(experiment: experiment, variation: variation, userId: kUserId, attributes: attributes, flagKey: "", ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true) + optimizely.sendImpressionEvent(experiment: experiment, variation: variation, userId: kUserId, attributes: attributes, flagKey: "", ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true, cmabUUID: nil) XCTAssert(eventDispatcher.events.count == 0) } @@ -321,7 +321,7 @@ class OptimizelyClientTests_Others: XCTestCase { // force condition for sdk-not-ready optimizely.config = nil - optimizely.sendImpressionEvent(experiment: experiment, variation: variation, userId: kUserId, flagKey: experiment.key, ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true) + optimizely.sendImpressionEvent(experiment: experiment, variation: variation, userId: kUserId, flagKey: experiment.key, ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true, cmabUUID: nil) XCTAssert(eventDispatcher.events.isEmpty, "event should not be sent out sdk is not configured properly") optimizely.sendConversionEvent(eventKey: kEventKey, userId: kUserId) diff --git a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift index 516f9ea35..d46fa2806 100644 --- a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift @@ -111,6 +111,7 @@ class BatchEventBuilderTests_Events: XCTestCase { XCTAssertEqual(metaData["rule_key"] as! String, "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2") XCTAssertEqual(metaData["flag_key"] as! String, "") XCTAssertEqual(metaData["variation_key"] as! String, "all_traffic_variation") + XCTAssertNil(metaData["cmab_uuid"]) XCTAssertTrue(metaData["enabled"] as! Bool) let de = (snapshot["events"] as! Array>)[0] @@ -212,7 +213,7 @@ class BatchEventBuilderTests_Events: XCTestCase { let experiment = optimizely.config?.getExperiment(id: "10390977714") optimizely.config?.project.sendFlagDecisions = true - let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, experiment: experiment!, variation: nil, userId: userId, attributes: attributes, flagKey: experiment!.key, ruleType: Constants.DecisionSource.featureTest.rawValue, enabled: false) + let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, experiment: experiment!, variation: nil, userId: userId, attributes: attributes, flagKey: experiment!.key, ruleType: Constants.DecisionSource.featureTest.rawValue, enabled: false, cmabUUID: "cmab_uuid_124") XCTAssertNotNil(event) let visitor = (getEventJSON(data: event!)!["visitors"] as! Array>)[0] @@ -224,6 +225,7 @@ class BatchEventBuilderTests_Events: XCTestCase { XCTAssertEqual(metaData["rule_key"] as! String, "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2") XCTAssertEqual(metaData["flag_key"] as! String, "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2") XCTAssertEqual(metaData["variation_key"] as! String, "") + XCTAssertEqual(metaData["cmab_uuid"] as! String, "cmab_uuid_124") XCTAssertFalse(metaData["enabled"] as! Bool) optimizely.config?.project.sendFlagDecisions = nil } @@ -231,7 +233,7 @@ class BatchEventBuilderTests_Events: XCTestCase { func testCreateImpressionEventWithoutExperimentAndVariation() { optimizely.config?.project.sendFlagDecisions = true - let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, experiment: nil, variation: nil, userId: userId, attributes: [String: Any](), flagKey: "feature_1", ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true) + let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, experiment: nil, variation: nil, userId: userId, attributes: [String: Any](), flagKey: "feature_1", ruleType: Constants.DecisionSource.rollout.rawValue, enabled: true, cmabUUID: nil) XCTAssertNotNil(event) let visitor = (getEventJSON(data: event!)!["visitors"] as! Array>)[0] @@ -243,6 +245,7 @@ class BatchEventBuilderTests_Events: XCTestCase { XCTAssertEqual(metaData["rule_key"] as! String, "") XCTAssertEqual(metaData["flag_key"] as! String, "feature_1") XCTAssertEqual(metaData["variation_key"] as! String, "") + XCTAssertEqual(metaData["cmab_uuid"] as? String, nil) XCTAssertTrue(metaData["enabled"] as! Bool) optimizely.config?.project.sendFlagDecisions = nil } diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift index 8e46eb03c..e90469534 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_CMAB.swift @@ -72,7 +72,30 @@ class OptimizelyUserContextTests_Decide_CMAB: XCTestCase { XCTAssertTrue(self.mockCmabService.decisionCalled, "CMAB decision service was not called") XCTAssertEqual(self.mockCmabService.lastRuleId, "10390977673", "Expected CMAB rule id '10390977673' but got \(String(describing: self.mockCmabService.lastRuleId))") + // Verify impression event + self.optimizely.eventLock.sync {} + + guard let event = self.getFirstEventJSON(client: self.optimizely) else { + XCTFail("No impression event found") + expectation.fulfill() + return + } + + let visitor = (event["visitors"] as! Array>)[0] + let snapshot = (visitor["snapshots"] as! Array>)[0] + let decision = (snapshot["decisions"] as! Array>)[0] + let metaData = decision["metadata"] as! Dictionary + + // Verify event metadata + XCTAssertEqual(metaData["rule_type"] as! String, Constants.DecisionSource.featureTest.rawValue) + XCTAssertEqual(metaData["rule_key"] as! String, "exp_with_audience") + XCTAssertEqual(metaData["flag_key"] as! String, "feature_1") + XCTAssertEqual(metaData["variation_key"] as! String, "a") + XCTAssertEqual(metaData["cmab_uuid"] as? String, "test-uuid") + XCTAssertTrue(metaData["enabled"] as! Bool) + expectation.fulfill() + } wait(for: [expectation], timeout: 5) // Increased timeout for reliability @@ -285,3 +308,31 @@ fileprivate class MockCmabService: DefaultCmabService { return .failure(CmabClientError.fetchFailed("No variation set")) } } + +extension OptimizelyUserContextTests_Decide_CMAB { + + func getFirstEvent(dispatcher: MockEventDispatcher) -> EventForDispatch? { + optimizely.eventLock.sync{} + return dispatcher.events.first + } + + func getFirstEventJSON(dispatcher: MockEventDispatcher) -> [String: Any]? { + guard let event = getFirstEvent(dispatcher: dispatcher) else { return nil } + + let json = try! JSONSerialization.jsonObject(with: event.body, options: .allowFragments) as! [String: Any] + return json + } + + func getFirstEventJSON(client: OptimizelyClient) -> [String: Any]? { + guard let event = getFirstEvent(dispatcher: client.eventDispatcher as! MockEventDispatcher) else { return nil } + + let json = try! JSONSerialization.jsonObject(with: event.body, options: .allowFragments) as! [String: Any] + return json + } + + func getEventJSON(data: Data) -> [String: Any]? { + let json = try! JSONSerialization.jsonObject(with: data, options: .allowFragments) as! [String: Any] + return json + } + +}