Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions Sources/CMAB/CmabClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ import Foundation

enum CmabClientError: Error, Equatable {
case fetchFailed(String)
case invalidResponse
case invalidResponse(String)

var message: String {
switch self {
case .fetchFailed(let message):
return message
case .invalidResponse:
return "Invalid response from CMA-B server"
case .invalidResponse(let reason):
return "Invalid response from CMA-B server: \(reason)"

}
}
Expand Down Expand Up @@ -159,34 +159,44 @@ class DefaultCmabClient: CmabClient {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody, options: []) else {
self.logger.e("Failed to encode request body: \(requestBody)")
completion(.failure(CmabClientError.fetchFailed("Failed to encode request body")))
return
}

request.httpBody = httpBody

self.logger.d("Fetching CMAB decision: \(url) with body: \(requestBody)")

let task = session.dataTask(with: request) { data, response, error in
if let error = error {
self.logger.e(error.localizedDescription)
completion(.failure(CmabClientError.fetchFailed(error.localizedDescription)))
return
}
guard let httpResponse = response as? HTTPURLResponse, let data = data, (200...299).contains(httpResponse.statusCode) else {
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
completion(.failure(CmabClientError.fetchFailed("HTTP error code: \(code)")))
let cmabError = CmabClientError.fetchFailed("HTTP error code: \(code)")
self.logger.e(cmabError.message)
completion(.failure(cmabError))
return
}
do {
if
let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
self.validateResponse(body: json),
let predictions = json["predictions"] as? [[String: Any]],
let variationId = predictions.first?["variation_id"] as? String
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
self.validateResponse(body: json),
let predictions = json["predictions"] as? [[String: Any]],
let variationId = predictions.first?["variation_id"] as? String
{
completion(.success(variationId))
} else {
completion(.failure(CmabClientError.invalidResponse))
let error = CmabClientError.invalidResponse("Response missing 'predictions' array or 'variation_id' field")
self.logger.e(error.message)
completion(.failure(error))
}
} catch {
completion(.failure(CmabClientError.invalidResponse))
let error = CmabClientError.invalidResponse("JSON parsing failed: \(error.localizedDescription)")
self.logger.e(error.message)
completion(.failure(error))
}
}
task.resume()
Expand Down
32 changes: 18 additions & 14 deletions Sources/CMAB/CmabService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ protocol CmabService {
typealias CmabCache = LruCache<String, CmabCacheValue>

class DefaultCmabService: CmabService {
typealias UserAttributes = [String : Any?]
typealias UserAttributes = [String: Any?]

let cmabClient: CmabClient
let cmabCache: CmabCache
Expand Down Expand Up @@ -97,33 +97,38 @@ class DefaultCmabService: CmabService {
let userId = userContext.userId

if options.contains(.ignoreCmabCache) {
self.logger.i("Ignoring CMAB cache.")
self.logger.i("Ignoring CMAB cache for user \(userId) and rule \(ruleId)")
fetchDecision(ruleId: ruleId, userId: userId, attributes: filteredAttributes, completion: completion)
return
}

if options.contains(.resetCmabCache) {
self.logger.i("Resetting CMAB cache.")
self.logger.i("Resetting CMAB cache for user \(userId) and rule \(ruleId)")
cmabCache.reset()
}

let cacheKey = getCacheKey(userId: userId, ruleId: ruleId)

if options.contains(.invalidateUserCmabCache) {
self.logger.i("Invalidating user CMAB cache.")
self.logger.i("Invalidating CMAB cache for user \(userId) and rule \(ruleId)")
self.cmabCache.remove(key: cacheKey)
}

let attributesHash = hashAttributes(filteredAttributes)

if let cachedValue = cmabCache.lookup(key: cacheKey), cachedValue.attributesHash == attributesHash {
let decision = CmabDecision(variationId: cachedValue.variationId, cmabUUID: cachedValue.cmabUUID)
self.logger.i("Returning cached CMAB decision.")
completion(.success(decision))
return
if let cachedValue = cmabCache.lookup(key: cacheKey) {
if cachedValue.attributesHash == attributesHash {
let decision = CmabDecision(variationId: cachedValue.variationId, cmabUUID: cachedValue.cmabUUID)
self.logger.i("CMAB cache hit for user \(userId) and rule \(ruleId)")
completion(.success(decision))
return
} else {
self.logger.i("CMAB cache attributes mismatch for user \(userId) and rule \(ruleId), fetching new decision")
cmabCache.remove(key: cacheKey)
}

} else {
self.logger.i("CMAB decision not found in cache.")
cmabCache.remove(key: cacheKey)
self.logger.i("CMAB cache miss for user \(userId) and rule \(ruleId)")
}

fetchDecision(ruleId: ruleId, userId: userId, attributes: filteredAttributes) { result in
Expand All @@ -133,7 +138,6 @@ class DefaultCmabService: CmabService {
variationId: decision.variationId,
cmabUUID: decision.cmabUUID
)
self.logger.i("Featched CMAB decision and cached it.")
self.cmabCache.save(key: cacheKey, value: cacheValue)
}
completion(result)
Expand All @@ -148,11 +152,11 @@ class DefaultCmabService: CmabService {
cmabClient.fetchDecision(ruleId: ruleId, userId: userId, attributes: attributes, cmabUUID: cmabUUID) { result in
switch result {
case .success(let variaitonId):
self.logger.i("Fetched CMAB decision: \(variaitonId)")
let decision = CmabDecision(variationId: variaitonId, cmabUUID: cmabUUID)
self.logger.i("Featched CMAB decision, (variationId: \(decision.variationId), cmabUUID: \(decision.cmabUUID))")
completion(.success(decision))
case .failure(let error):
self.logger.e("Failed to fetch CMAB decision: \(error)")
self.logger.e("Failed to fetch CMAB decision, error: \(error)")
completion(.failure(error))
}
}
Expand Down
6 changes: 4 additions & 2 deletions Sources/Implementation/DefaultDecisionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ typealias UserProfile = OPTUserProfileService.UPProfile
class DefaultDecisionService: OPTDecisionService {
let bucketer: OPTBucketer
let userProfileService: OPTUserProfileService
let cmabService: CmabService
var cmabService: CmabService
let group: DispatchGroup = DispatchGroup()
// thread-safe lazy logger load (after HandlerRegisterService ready)
private let threadSafeLogger = ThreadSafeLogger()
Expand Down Expand Up @@ -123,6 +123,9 @@ class DefaultDecisionService: OPTDecisionService {
switch response {
case .success(let decision):
cmabDecision = decision
let info = LogMessage.cmabFetchSuccess(decision.variationId, decision.cmabUUID, _expKey: experiment.key)
self.logger.d(info)
reasons.addInfo(info)
case .failure:
let info = LogMessage.cmabFetchFailed(experiment.key)
self.logger.e(info)
Expand Down Expand Up @@ -239,7 +242,6 @@ class DefaultDecisionService: OPTDecisionService {
return DecisionResponse(result: variationDecision, reasons: reasons)
}


var variationDecision: VariationDecision?
// ---- check if the user passes audience targeting before bucketing ----
let audienceResponse = doesMeetAudienceConditions(config: config,
Expand Down
10 changes: 7 additions & 3 deletions Sources/Optimizely/OptimizelyClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,24 @@ open class OptimizelyClient: NSObject {
let logger = logger ?? DefaultLogger()
type(of: logger).logLevel = defaultLogLevel ?? .info

let cmabService = DefaultCmabService.createDefault(config: cmabConfig ?? CmabConfig())

self.registerServices(sdkKey: sdkKey,
logger: logger,
eventDispatcher: eventDispatcher ?? DefaultEventDispatcher.sharedInstance,
datafileHandler: datafileHandler ?? DefaultDatafileHandler(),
decisionService: DefaultDecisionService(userProfileService: userProfileService, cmabService: cmabService),
decisionService: DefaultDecisionService(userProfileService: userProfileService),
notificationCenter: DefaultNotificationCenter())

self.logger = HandlerRegistryService.shared.injectLogger()
self.eventDispatcher = HandlerRegistryService.shared.injectEventDispatcher(sdkKey: self.sdkKey)
self.datafileHandler = HandlerRegistryService.shared.injectDatafileHandler(sdkKey: self.sdkKey)
self.decisionService = HandlerRegistryService.shared.injectDecisionService(sdkKey: self.sdkKey)
self.notificationCenter = HandlerRegistryService.shared.injectNotificationCenter(sdkKey: self.sdkKey)

// Set cmabService after injection to handle re-initialization with the same sdkKey.
// HandlerRegistryService won't replace existing bindings, so we mutate the instance instead.
let cmabService = DefaultCmabService.createDefault(config: cmabConfig ?? CmabConfig())
(self.decisionService as? DefaultDecisionService)?.cmabService = cmabService

if let _vuid = vuid {
self.odpManager.vuid = _vuid
sendInitializedEvent(vuid: _vuid)
Expand Down
2 changes: 2 additions & 0 deletions Sources/Utils/LogMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ enum LogMessage {
case failedToAssignValue
case valueForKeyNotFound(_ key: String)
case lowPeriodicDownloadInterval
case cmabFetchSuccess(_ variationId: String, _ cmabUUID: String, _expKey: String)
case cmabFetchFailed(_ expKey: String)
case cmabNotSupportedInSyncMode
}
Expand Down Expand Up @@ -148,6 +149,7 @@ extension LogMessage: CustomStringConvertible {
case .failedToAssignValue: message = "Value for path could not be assigned to provided type."
case .valueForKeyNotFound(let key): message = "Value for JSON key (\(key)) not found."
case .lowPeriodicDownloadInterval: message = "Polling intervals below 30 seconds are not recommended."
case .cmabFetchSuccess(let variationId, let cmabUUID, let expKey): message = "Successfully fetched CMAB decision, variationId: \(variationId), cmabUUID: \(cmabUUID) for experiment \(expKey)."
case .cmabFetchFailed(let key): message = "Failed to fetch CMAB data for experiment \(key)."
case .cmabNotSupportedInSyncMode: message = "CMAB is not supported in sync mode."
}
Expand Down
52 changes: 52 additions & 0 deletions Tests/OptimizelyTests-APIs/OptimizelyClientTests_Cmab_Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,56 @@ class OptimizelyClientTests_Cmab_Config: XCTestCase {
XCTAssertEqual(1800, cmabCache.timeoutInSecs)
XCTAssertEqual("http://fowardslash.com/predict/rule-12345/v1/", cmabClient.getUrl(ruleId: "rule-12345")?.absoluteString)
}

// MARK: - Re-initialization with Same SDK Key

func test_cmab_reinitialization_same_sdkKey_updates_config() {
let sdkKey = "test-sdk-key-reinit"

// First initialization with 3 second timeout
let config1 = CmabConfig(cacheSize: 10, cacheTimeoutInSecs: 3,
predictionEndpoint: "https://endpoint1.com/%@")
var optimizely = OptimizelyClient(sdkKey: sdkKey, cmabConfig: config1)
var cmabService = ((optimizely.decisionService as! DefaultDecisionService).cmabService as! DefaultCmabService)
var cmabCache = cmabService.cmabCache
var cmabClient = cmabService.cmabClient as! DefaultCmabClient

XCTAssertEqual(10, cmabCache.maxSize, "First init: cache size should be 10")
XCTAssertEqual(3, cmabCache.timeoutInSecs, "First init: cache timeout should be 3")
XCTAssertEqual("https://endpoint1.com/%@", cmabClient.predictionEndpoint, "First init: should use endpoint1")

// Re-initialize with SAME sdkKey but different config (5 second timeout)
let config2 = CmabConfig(cacheSize: 50, cacheTimeoutInSecs: 5,
predictionEndpoint: "https://endpoint2.com/%@")
optimizely = OptimizelyClient(sdkKey: sdkKey, cmabConfig: config2)
cmabService = ((optimizely.decisionService as! DefaultDecisionService).cmabService as! DefaultCmabService)
cmabCache = cmabService.cmabCache
cmabClient = cmabService.cmabClient as! DefaultCmabClient

XCTAssertEqual(50, cmabCache.maxSize, "Second init: cache size should be updated to 50")
XCTAssertEqual(5, cmabCache.timeoutInSecs, "Second init: cache timeout should be updated to 5")
XCTAssertEqual("https://endpoint2.com/%@", cmabClient.predictionEndpoint, "Second init: endpoint should be updated")
}

func test_cmab_different_sdkKeys_maintain_separate_configs() {
// Initialize first client with sdkKey1
let sdkKey1 = "test-sdk-key-1"
let config1 = CmabConfig(cacheSize: 10, cacheTimeoutInSecs: 100)
let client1 = OptimizelyClient(sdkKey: sdkKey1, cmabConfig: config1)
let cmabService1 = ((client1.decisionService as! DefaultDecisionService).cmabService as! DefaultCmabService)
let cmabCache1 = cmabService1.cmabCache

// Initialize second client with sdkKey2
let sdkKey2 = "test-sdk-key-2"
let config2 = CmabConfig(cacheSize: 50, cacheTimeoutInSecs: 500)
let client2 = OptimizelyClient(sdkKey: sdkKey2, cmabConfig: config2)
let cmabService2 = ((client2.decisionService as! DefaultDecisionService).cmabService as! DefaultCmabService)
let cmabCache2 = cmabService2.cmabCache

// Verify both clients maintain their own configs independently
XCTAssertEqual(10, cmabCache1.maxSize, "Client 1 should have cache size 10")
XCTAssertEqual(100, cmabCache1.timeoutInSecs, "Client 1 should have timeout 100")
XCTAssertEqual(50, cmabCache2.maxSize, "Client 2 should have cache size 50")
XCTAssertEqual(500, cmabCache2.timeoutInSecs, "Client 2 should have timeout 500")
}
}
16 changes: 12 additions & 4 deletions Tests/OptimizelyTests-Common/CMABClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,19 @@ class DefaultCmabClientTests: XCTestCase {
(Data("not a json".utf8), HTTPURLResponse(url: URL(string: "https://prediction.cmab.optimizely.com/predict/abc")!,
statusCode: 200, httpVersion: nil, headerFields: nil), nil)
]

let expectation = self.expectation(description: "Completion called")
client.fetchDecision(
ruleId: "abc", userId: "user1",
attributes: ["foo": "bar"],
attributes: ["foo": "bar"],
cmabUUID: "uuid"
) { result in
if case let .failure(error) = result {
XCTAssertTrue(error is CmabClientError)
if case .invalidResponse(let reason) = error as? CmabClientError {
XCTAssertTrue(reason.contains("JSON parsing failed"))
} else {
XCTFail("Expected CmabClientError.invalidResponse for JSON parsing failure")
}
XCTAssertEqual(self.mockSession.callCount, 1)
} else {
XCTFail("Expected failure on invalid JSON")
Expand All @@ -209,7 +213,11 @@ class DefaultCmabClientTests: XCTestCase {
cmabUUID: "uuid-1234"
) { result in
if case let .failure(error) = result {
XCTAssertEqual(error as? CmabClientError, .invalidResponse)
if case .invalidResponse(let reason) = error as? CmabClientError {
XCTAssertTrue(reason.contains("Response missing 'predictions' array or 'variation_id' field"))
} else {
XCTFail("Expected CmabClientError.invalidResponse")
}
XCTAssertEqual(self.mockSession.callCount, 1)
} else {
XCTFail("Expected failure on invalid response structure")
Expand Down
Loading