diff --git a/Sources/Data Model/DispatchEvents/BatchEvent.swift b/Sources/Data Model/DispatchEvents/BatchEvent.swift index 345c7b85b..5c7a26f9a 100644 --- a/Sources/Data Model/DispatchEvents/BatchEvent.swift +++ b/Sources/Data Model/DispatchEvents/BatchEvent.swift @@ -25,6 +25,7 @@ struct BatchEvent: Codable, Equatable { let clientName: String let anonymizeIP: Bool let enrichDecisions: Bool + let region: String enum CodingKeys: String, CodingKey { case revision @@ -35,6 +36,7 @@ struct BatchEvent: Codable, Equatable { case clientName = "client_name" case anonymizeIP = "anonymize_ip" case enrichDecisions = "enrich_decisions" + case region } func getEventAttribute(key: String) -> EventAttribute? { diff --git a/Sources/Data Model/DispatchEvents/EventForDispatch.swift b/Sources/Data Model/DispatchEvents/EventForDispatch.swift index 88cf5d83c..966e1f331 100644 --- a/Sources/Data Model/DispatchEvents/EventForDispatch.swift +++ b/Sources/Data Model/DispatchEvents/EventForDispatch.swift @@ -18,12 +18,14 @@ import Foundation @objcMembers public class EventForDispatch: NSObject, Codable { public static var eventEndpoint = "https://logx.optimizely.com/v1/events" + public static var euEventEndpoint = "https://eu.logx.optimizely.com/v1/events" public let url: URL public let body: Data - public init(url: URL? = nil, body: Data) { - self.url = url ?? URL(string: EventForDispatch.eventEndpoint)! + public init(url: URL? = nil, body: Data, region: Region = .US) { + let endpoint = url?.absoluteString ?? (region == .US ? Self.eventEndpoint : Self.euEventEndpoint) + self.url = URL(string: endpoint)! self.body = body } diff --git a/Sources/Data Model/Project.swift b/Sources/Data Model/Project.swift index 3a518c25d..95d58248d 100644 --- a/Sources/Data Model/Project.swift +++ b/Sources/Data Model/Project.swift @@ -16,6 +16,12 @@ import Foundation +/// Optimizely region identifiers +public enum Region: String, Codable, Equatable { + case US + case EU +} + protocol ProjectProtocol { func evaluateAudience(audienceId: String, user: OptimizelyUserContext) throws -> Bool } @@ -48,6 +54,8 @@ struct Project: Codable, Equatable { var environmentKey: String? // Holdouts var holdouts: [Holdout] + // Region + var region: Region? let logger = OPTLoggerFactory.getLogger() // Required since logger is not decodable @@ -57,7 +65,7 @@ struct Project: Codable, Equatable { // V3 case anonymizeIP // V4 - case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts + case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts, region } init(from decoder: Decoder) throws { @@ -88,6 +96,8 @@ struct Project: Codable, Equatable { environmentKey = try container.decodeIfPresent(String.self, forKey: .environmentKey) // Holdouts - defaults to empty array if key is not present holdouts = try container.decodeIfPresent([Holdout].self, forKey: .holdouts) ?? [] + // Region - defaults to US if not present + region = try container.decodeIfPresent(Region.self, forKey: .region) } // Required since logger is not equatable @@ -97,7 +107,9 @@ struct Project: Codable, Equatable { lhs.accountId == rhs.accountId && lhs.events == rhs.events && lhs.revision == rhs.revision && lhs.anonymizeIP == rhs.anonymizeIP && lhs.rollouts == rhs.rollouts && lhs.integrations == rhs.integrations && lhs.typedAudiences == rhs.typedAudiences && - lhs.featureFlags == rhs.featureFlags && lhs.botFiltering == rhs.botFiltering && lhs.sendFlagDecisions == rhs.sendFlagDecisions && lhs.sdkKey == rhs.sdkKey && lhs.environmentKey == rhs.environmentKey + lhs.featureFlags == rhs.featureFlags && lhs.botFiltering == rhs.botFiltering && + lhs.sendFlagDecisions == rhs.sendFlagDecisions && lhs.sdkKey == rhs.sdkKey && + lhs.environmentKey == rhs.environmentKey && lhs.region == rhs.region } } diff --git a/Sources/Data Model/ProjectConfig.swift b/Sources/Data Model/ProjectConfig.swift index a2cd3bf28..be2c71d67 100644 --- a/Sources/Data Model/ProjectConfig.swift +++ b/Sources/Data Model/ProjectConfig.swift @@ -215,6 +215,13 @@ extension ProjectConfig { extension ProjectConfig { + /** + * Get the region value. Defaults to US if not specified in the project. + */ + public var region: Region { + return project.region ?? .US + } + /** * Get sendFlagDecisions value. */ diff --git a/Sources/Extensions/ArrayEventForDispatch+Extension.swift b/Sources/Extensions/ArrayEventForDispatch+Extension.swift index 5d8e5d28f..17855f22e 100644 --- a/Sources/Extensions/ArrayEventForDispatch+Extension.swift +++ b/Sources/Extensions/ArrayEventForDispatch+Extension.swift @@ -45,6 +45,7 @@ extension Array where Element == EventForDispatch { var url: URL? var projectId: String? var revision: String? + var region: String? let checkUrl = { (event: EventForDispatch) -> Bool in if url == nil { @@ -69,10 +70,18 @@ extension Array where Element == EventForDispatch { } return revision == batchEvent.revision } + + let checkRegion = { (batchEvent: BatchEvent) -> Bool in + if region == nil { + region = batchEvent.region + return region != nil + } + return region == batchEvent.region + } for event in self { if let batchEvent = try? JSONDecoder().decode(BatchEvent.self, from: event.body) { - if !checkUrl(event) || !checkProjectId(batchEvent) || !checkRevision(batchEvent) { + if !checkUrl(event) || !checkProjectId(batchEvent) || !checkRevision(batchEvent) || !checkRegion(batchEvent) { break } @@ -101,12 +110,13 @@ extension Array where Element == EventForDispatch { projectID: base.projectID, clientName: base.clientName, anonymizeIP: base.anonymizeIP, - enrichDecisions: true) + enrichDecisions: true, + region: base.region) guard let data = try? JSONEncoder().encode(batchEvent) else { return nil } - - return EventForDispatch(url: url, body: data) + + return EventForDispatch(url: url, body: data, region: Region(rawValue: base.region) ?? .US) } } diff --git a/Sources/Implementation/Events/BatchEventBuilder.swift b/Sources/Implementation/Events/BatchEventBuilder.swift index 4027b0321..57db1dcb7 100644 --- a/Sources/Implementation/Events/BatchEventBuilder.swift +++ b/Sources/Implementation/Events/BatchEventBuilder.swift @@ -87,6 +87,7 @@ class BatchEventBuilder { attributes: OptimizelyAttributes?, decisions: [Decision]?, dispatchEvents: [DispatchEvent]) -> Data? { + let eventRegion = config.region let snapShot = Snapshot(decisions: decisions, events: dispatchEvents) let eventAttributes = getEventAttributes(config: config, attributes: attributes) @@ -100,9 +101,13 @@ class BatchEventBuilder { projectID: config.project.projectId, clientName: Utils.swiftSdkClientName, anonymizeIP: config.project.anonymizeIP, - enrichDecisions: true) + enrichDecisions: true, + region: eventRegion.rawValue) + + let data = try? JSONEncoder().encode(batchEvent) + let eventForDispatch = EventForDispatch(url: nil, body: data ?? Data(), region: eventRegion) - return try? JSONEncoder().encode(batchEvent) + return eventForDispatch.body } // MARK: - Event Tags diff --git a/Sources/Optimizely+Decide/OptimizelyUserContext+ObjC.swift b/Sources/Optimizely+Decide/OptimizelyUserContext+ObjC.swift index fa2a79b58..83a90a68c 100644 --- a/Sources/Optimizely+Decide/OptimizelyUserContext+ObjC.swift +++ b/Sources/Optimizely+Decide/OptimizelyUserContext+ObjC.swift @@ -35,7 +35,7 @@ import Foundation public init(optimizely: OptimizelyClient, userId: String, attributes: [String: Any]? = nil) { userContext = OptimizelyUserContext(optimizely: optimizely, userId: userId, attributes: attributes) } - + public init(user: OptimizelyUserContext) { self.userContext = user } diff --git a/Sources/Optimizely/OptimizelyClient+ObjC.swift b/Sources/Optimizely/OptimizelyClient+ObjC.swift index 54161ad5b..fc7abd69f 100644 --- a/Sources/Optimizely/OptimizelyClient+ObjC.swift +++ b/Sources/Optimizely/OptimizelyClient+ObjC.swift @@ -546,3 +546,10 @@ extension OptimizelyClient { } } + +// MARK: - EventForDispatch Objective-C initializer +extension EventForDispatch { + @objc public convenience init(url: URL? = nil, body: Data) { + self.init(url: url, body: body, region: .US) + } +} diff --git a/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift b/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift index 946cea17c..c5d0da8c8 100644 --- a/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift +++ b/Tests/OptimizelyTests-Batch-iOS/EventDispatcherTests_Batch.swift @@ -950,7 +950,8 @@ extension EventDispatcherTests_Batch { projectID: testProjectId, clientName: kClientName, anonymizeIP: kAnonymizeIP, - enrichDecisions: kEnrichDecision) + enrichDecisions: kEnrichDecision, + region: "US") } func dispatchMultipleEvents(_ events: [(url: String, event: BatchEvent)]) { diff --git a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Region.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Region.swift new file mode 100644 index 000000000..1d00f65e1 --- /dev/null +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Region.swift @@ -0,0 +1,396 @@ +// +// Copyright 2023-2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class BatchEventBuilderTests_Region: XCTestCase { + + let experimentKey = "ab_running_exp_audience_combo_exact_foo_or_true__and__42_or_4_2" + let userId = "test_user_1" + let featureKey = "feature_1" + + var optimizely: OptimizelyClient! + var eventDispatcher: MockEventDispatcher! + var project: Project! + let datafile = OTUtils.loadJSONDatafile("api_datafile")! + + override func setUp() { + eventDispatcher = MockEventDispatcher() + optimizely = OTUtils.createOptimizely(datafileName: "audience_targeting", + clearUserProfileService: true, + eventDispatcher: eventDispatcher)! + project = optimizely.config!.project! + } + + override func tearDown() { + Utils.sdkVersion = OPTIMIZELYSDKVERSION + Utils.swiftSdkClientName = "swift-sdk" + optimizely?.close() + optimizely = nil + optimizely?.eventDispatcher = nil + super.tearDown() + } + + // MARK: - Test Impression Event with Region + + func testCreateImpressionEventWithUSRegion() { + // Set the region to US + optimizely.config?.project.region = .US + + let attributes: [String: Any] = [ + "s_foo": "foo", + "b_true": true, + "i_42": 42, + "d_4_2": 4.2 + ] + + _ = try! optimizely.activate(experimentKey: experimentKey, + userId: userId, + attributes: attributes) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to US in the event + XCTAssertEqual(event["region"] as! String, "US") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.eventEndpoint) + } + + func testCreateImpressionEventWithEURegion() { + // Set the region to EU + optimizely.config?.project.region = .EU + + let attributes: [String: Any] = [ + "s_foo": "foo", + "b_true": true, + "i_42": 42, + "d_4_2": 4.2 + ] + + _ = try! optimizely.activate(experimentKey: experimentKey, + userId: userId, + attributes: attributes) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to EU in the event + XCTAssertEqual(event["region"] as! String, "EU") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.euEventEndpoint) + } + + func testCreateImpressionEventWithInvalidRegion() { + // Set the region to invalid ZZ + optimizely.config?.project.region = .ZZ + + let attributes: [String: Any] = [ + "s_foo": "foo", + "b_true": true, + "i_42": 42, + "d_4_2": 4.2 + ] + + _ = try! optimizely.activate(experimentKey: experimentKey, + userId: userId, + attributes: attributes) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to default US in the event + XCTAssertEqual(event["region"] as! String, "US") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.eventEndpoint) + } + + // MARK: - Test Conversion Event with Region + + func testCreateConversionEventWithUSRegion() { + // Set the region to US + optimizely.config?.project.region = .US + + let eventKey = "event_single_targeted_exp" + let attributes: [String: Any] = ["s_foo": "bar"] + let eventTags: [String: Any] = ["browser": "chrome"] + + try! optimizely.track(eventKey: eventKey, + userId: userId, + attributes: attributes, + eventTags: eventTags) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to US in the event + XCTAssertEqual(event["region"] as! String, "US") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.eventEndpoint) + } + + func testCreateConversionEventWithEURegion() { + // Set the region to EU + optimizely.config?.project.region = .EU + + let eventKey = "event_single_targeted_exp" + let attributes: [String: Any] = ["s_foo": "bar"] + let eventTags: [String: Any] = ["browser": "chrome"] + + try! optimizely.track(eventKey: eventKey, + userId: userId, + attributes: attributes, + eventTags: eventTags) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to EU in the event + XCTAssertEqual(event["region"] as! String, "EU") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.euEventEndpoint) + } + + func testCreateConversionEventWithInvalidRegion() { + // Set the region to invalid ZZ + optimizely.config?.project.region = .ZZ + + let eventKey = "event_single_targeted_exp" + let attributes: [String: Any] = ["s_foo": "bar"] + let eventTags: [String: Any] = ["browser": "chrome"] + + try! optimizely.track(eventKey: eventKey, + userId: userId, + attributes: attributes, + eventTags: eventTags) + + let event = getFirstEventJSON(dispatcher: eventDispatcher)! + + // Check if the region is correctly set to default US in the event + XCTAssertEqual(event["region"] as! String, "US") + + // Check if the event was sent to the correct endpoint + let eventForDispatch = getFirstEvent(dispatcher: eventDispatcher)! + XCTAssertEqual(eventForDispatch.url.absoluteString, EventForDispatch.eventEndpoint) + } + + // MARK: - Test Direct Event Creation with Region + + func testDirectImpressionEventCreationWithUSRegion() { + // Set the region to US + optimizely.config?.project.region = .US + + let experiment = optimizely.config?.getExperiment(id: "10390977714") + let variation = experiment?.variations.first + + let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId, + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + XCTAssertNotNil(event) + + let eventJson = getEventJSON(data: event!)! + + // Check if the region is correctly set to US in the event + XCTAssertEqual(eventJson["region"] as! String, "US") + } + + func testDirectImpressionEventCreationWithEURegion() { + // Set the region to EU + optimizely.config?.project.region = .EU + + let experiment = optimizely.config?.getExperiment(id: "10390977714") + let variation = experiment?.variations.first + + let event = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId, + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + XCTAssertNotNil(event) + + let eventJson = getEventJSON(data: event!)! + + // Check if the region is correctly set to EU in the event + XCTAssertEqual(eventJson["region"] as! String, "EU") + } + + func testDirectConversionEventCreationWithUSRegion() { + // Set the region to US + optimizely.config?.project.region = .US + + let eventKey = "event_single_targeted_exp" + let eventTags: [String: Any] = ["browser": "chrome"] + + let event = BatchEventBuilder.createConversionEvent(config: optimizely.config!, + eventKey: eventKey, + userId: userId, + attributes: nil, + eventTags: eventTags) + + XCTAssertNotNil(event) + + let eventJson = getEventJSON(data: event!)! + + // Check if the region is correctly set to US in the event + XCTAssertEqual(eventJson["region"] as! String, "US") + } + + func testDirectConversionEventCreationWithEURegion() { + // Set the region to EU + optimizely.config?.project.region = .EU + + let eventKey = "event_single_targeted_exp" + let eventTags: [String: Any] = ["browser": "chrome"] + + let event = BatchEventBuilder.createConversionEvent(config: optimizely.config!, + eventKey: eventKey, + userId: userId, + attributes: nil, + eventTags: eventTags) + + XCTAssertNotNil(event) + + let eventJson = getEventJSON(data: event!)! + + // Check if the region is correctly set to EU in the event + XCTAssertEqual(eventJson["region"] as! String, "EU") + } + + // MARK: - Test Event Batching with Region + + func testEventBatchingWithSameRegion() { + // Set the region to US + optimizely.config?.project.region = .US + + // Create two events with the same region + let experiment = optimizely.config?.getExperiment(id: "10390977714") + let variation = experiment?.variations.first + + // Create first event + let event1 = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId, + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + // Create second event + let event2 = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId + "2", + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + // Create EventForDispatch objects + let eventForDispatch1 = EventForDispatch(url: nil, body: event1!, region: .US) + let eventForDispatch2 = EventForDispatch(url: nil, body: event2!, region: .US) + + // Test batching + let batchResult = [eventForDispatch1, eventForDispatch2].batch() + + // Events should be batched together since they have the same region + XCTAssertEqual(batchResult.numEvents, 2) + XCTAssertNotNil(batchResult.eventForDispatch) + } + + func testEventBatchingWithDifferentRegions() { + // Create two events with different regions + let experiment = optimizely.config?.getExperiment(id: "10390977714") + let variation = experiment?.variations.first + + // Set region to US for first event + optimizely.config?.project.region = .US + + // Create first event (US) + let event1 = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId, + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + // Set region to EU for second event + optimizely.config?.project.region = .EU + + // Create second event (EU) + let event2 = BatchEventBuilder.createImpressionEvent(config: optimizely.config!, + experiment: experiment, + variation: variation, + userId: userId + "2", + attributes: nil, + flagKey: experiment!.key, + ruleType: Constants.DecisionSource.experiment.rawValue, + enabled: true, + cmabUUID: nil) + + // Create EventForDispatch objects + let eventForDispatch1 = EventForDispatch(url: nil, body: event1!, region: .US) + let eventForDispatch2 = EventForDispatch(url: nil, body: event2!, region: .EU) + + // Test batching + let batchResult = [eventForDispatch1, eventForDispatch2].batch() + + // Only the first event should be batched as they have different regions + XCTAssertEqual(batchResult.numEvents, 1) + XCTAssertNotNil(batchResult.eventForDispatch) + } + + // MARK: - Utils + + 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 getEventJSON(data: Data) -> [String: Any]? { + let json = try! JSONSerialization.jsonObject(with: data, options: .allowFragments) as! [String: Any] + return json + } +} diff --git a/Tests/TestUtils/OTUtils.swift b/Tests/TestUtils/OTUtils.swift index 426338390..549a71284 100644 --- a/Tests/TestUtils/OTUtils.swift +++ b/Tests/TestUtils/OTUtils.swift @@ -232,7 +232,8 @@ class OTUtils { projectID: testProjectId, clientName: "test", anonymizeIP: true, - enrichDecisions: true) + enrichDecisions: true, + region: "US") } static func clearAllEventQueues() {