Skip to content
Merged
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Optimizely Swift SDK Changelog

## 5.4.2
July 9, 2026
July 9th, 2026

### Fixes

Expand All @@ -15,7 +15,7 @@ June 24, 2026

### Fixes

- normalize campaign_id, variation_id, and entity_id on decision events ([#642](https://github.com/optimizely/swift-sdk/pull/642))
- Normalize campaign_id, variation_id, and entity_id on decision events ([#642](https://github.com/optimizely/swift-sdk/pull/642))

## 5.4.0
June 22, 2026
Expand Down
6 changes: 6 additions & 0 deletions OptimizelySwiftSDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2121,6 +2121,8 @@
98AC985F2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */; };
98C2DF242F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */; };
98C2DF252F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */; };
E793710B634841BF89AA931E /* DecisionServiceTests_ExcludeTargetedDeliveries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA2AD71FDC644278FE20412 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift */; };
BC46ADF752734A65854A6676 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA2AD71FDC644278FE20412 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift */; };
98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; };
98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; };
98F28A1D2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; };
Expand Down Expand Up @@ -2633,6 +2635,7 @@
98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_Holdouts.swift; sourceTree = "<group>"; };
98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift; sourceTree = "<group>"; };
98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_LocalHoldouts.swift; sourceTree = "<group>"; };
6EA2AD71FDC644278FE20412 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_ExcludeTargetedDeliveries.swift; sourceTree = "<group>"; };
98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Holdouts.swift; sourceTree = "<group>"; };
98F28A1C2E01940500A86546 /* Cmab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cmab.swift; sourceTree = "<group>"; };
98F28A2D2E01968000A86546 /* CmabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3171,6 +3174,7 @@
6E75198B22C5211100B2B157 /* NotificationCenterTests.swift */,
84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */,
98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */,
6EA2AD71FDC644278FE20412 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift */,
8486180E286D0B8900B7F41B /* OdpManagerTests.swift */,
8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */,
98F28A512E02E81500A86546 /* CMABClientTests.swift */,
Expand Down Expand Up @@ -5138,6 +5142,7 @@
6E75187922C520D400B2B157 /* Variation.swift in Sources */,
6E75191522C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */,
98C2DF242F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */,
E793710B634841BF89AA931E /* DecisionServiceTests_ExcludeTargetedDeliveries.swift in Sources */,
6E75195D22C520D500B2B157 /* OPTBucketer.swift in Sources */,
6E9B117622C5487100C22D81 /* DatafileHandlerTests.swift in Sources */,
84E2E97F2855875E001114AB /* OdpEventManager.swift in Sources */,
Expand Down Expand Up @@ -5443,6 +5448,7 @@
6E7516FD22C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */,
6E75187322C520D400B2B157 /* Variation.swift in Sources */,
98C2DF252F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */,
BC46ADF752734A65854A6676 /* DecisionServiceTests_ExcludeTargetedDeliveries.swift in Sources */,
6E7517E322C520D400B2B157 /* DefaultDecisionService.swift in Sources */,
6E75179922C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */,
6E9B115C22C5486E00C22D81 /* DatafileHandlerTests.swift in Sources */,
Expand Down
9 changes: 6 additions & 3 deletions Sources/Data Model/Holdout.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2022, Optimizely, Inc. and contributors
// Copyright 2022, 2026, 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.
Expand Down Expand Up @@ -32,9 +32,10 @@ struct Holdout: Codable, ExperimentCore {
var audienceIds: [String]
var audienceConditions: ConditionHolder?
var includedRules: [String]?
var excludeTargetedDeliveries: Bool

enum CodingKeys: String, CodingKey {
case id, key, status, variations, trafficAllocation, audienceIds, audienceConditions, includedRules
case id, key, status, variations, trafficAllocation, audienceIds, audienceConditions, includedRules, excludeTargetedDeliveries
}

var variationsMap: [String: OptimizelyVariation] = [:]
Expand All @@ -55,6 +56,7 @@ struct Holdout: Codable, ExperimentCore {
audienceConditions = try container.decodeIfPresent(ConditionHolder.self, forKey: .audienceConditions)

includedRules = try container.decodeIfPresent([String].self, forKey: .includedRules)
excludeTargetedDeliveries = try container.decodeIfPresent(Bool.self, forKey: .excludeTargetedDeliveries) ?? false
}
}

Expand All @@ -67,7 +69,8 @@ extension Holdout: Equatable {
lhs.trafficAllocation == rhs.trafficAllocation &&
lhs.audienceIds == rhs.audienceIds &&
lhs.audienceConditions == rhs.audienceConditions &&
lhs.includedRules == rhs.includedRules
lhs.includedRules == rhs.includedRules &&
lhs.excludeTargetedDeliveries == rhs.excludeTargetedDeliveries
}
}

Expand Down
60 changes: 47 additions & 13 deletions Sources/Implementation/DefaultDecisionService.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2019-2022, Optimizely, Inc. and contributors
// Copyright 2019-2022, 2026, 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.
Expand All @@ -16,12 +16,25 @@

import Foundation

struct HoldoutDecision {
let experiment: ExperimentCore
let variation: Variation

init?(from featureDecision: FeatureDecision?) {
guard let experiment = featureDecision?.experiment,
let variation = featureDecision?.variation else { return nil }
self.experiment = experiment
self.variation = variation
}
}

struct FeatureDecision {
var experiment: ExperimentCore?
let variation: Variation?
let source: String
var cmabUUID: String?
var error = false
var holdoutDecision: HoldoutDecision? = nil
}

struct VariationDecision {
Expand Down Expand Up @@ -400,6 +413,8 @@ class DefaultDecisionService: OPTDecisionService {
options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
let reasons = DecisionReasons(options: options)

var storedHoldoutDecision: FeatureDecision?

let holdouts = config.getGlobalHoldouts()
for holdout in holdouts {
let holdoutDecision = getVariationForHoldout(config: config,
Expand All @@ -410,24 +425,45 @@ class DefaultDecisionService: OPTDecisionService {
reasons.merge(holdoutDecision.reasons)
if let variation = holdoutDecision.result {
let featureDecision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue)
return DecisionResponse(result: featureDecision, reasons: reasons)
if holdout.excludeTargetedDeliveries {
storedHoldoutDecision = featureDecision
let info = LogMessage.holdoutExcludeTargetedDeliveriesEnabled(holdout.key)
reasons.addInfo(info)
} else {
return DecisionResponse(result: featureDecision, reasons: reasons)
}
break
}
}

let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, isAsync: isAsync, options: options)

reasons.merge(flagExpDecision.reasons)

if let decision = flagExpDecision.result {
return DecisionResponse(result: decision, reasons: reasons)
if storedHoldoutDecision == nil {
let flagExpDecision = getVariationForFeatureExperiments(config: config, featureFlag: featureFlag, user: user, userProfileTracker: userProfileTracker, isAsync: isAsync, options: options)

reasons.merge(flagExpDecision.reasons)

if let decision = flagExpDecision.result {
return DecisionResponse(result: decision, reasons: reasons)
}
}

let rolloutDecision = getVariationForFeatureRollout(config: config, featureFlag: featureFlag, user: user, options: options)
reasons.merge(rolloutDecision.reasons)

if let decision = rolloutDecision.result {

if var decision: FeatureDecision = rolloutDecision.result {
decision.holdoutDecision = HoldoutDecision(from: storedHoldoutDecision)
let info = LogMessage.userBucketedIntoRollout(user.userId, featureFlag.key)
logger.i(info)
reasons.addInfo(info)
return DecisionResponse(result: decision, reasons: reasons)
} else {
let info = LogMessage.userNotBucketedIntoRollout(user.userId, featureFlag.key)
logger.i(info)
reasons.addInfo(info)
if let holdout = HoldoutDecision(from: storedHoldoutDecision) {
var emptyDecision = FeatureDecision(experiment: nil, variation: nil, source: Constants.DecisionSource.rollout.rawValue)
emptyDecision.holdoutDecision = holdout
return DecisionResponse(result: emptyDecision, reasons: reasons)
}
return DecisionResponse(result: nil, reasons: reasons)
}
}
Expand Down Expand Up @@ -667,7 +703,6 @@ class DefaultDecisionService: OPTDecisionService {
options: options)
reasons.merge(holdoutDecision.reasons)
if let variation = holdoutDecision.result {
// User is in holdout — return holdout variation immediately, skip this rule
let variationDecision = VariationDecision(variation: variation, holdout: holdout)
return DecisionResponse(result: variationDecision, reasons: reasons)
}
Expand Down Expand Up @@ -725,7 +760,6 @@ class DefaultDecisionService: OPTDecisionService {
options: options)
reasons.merge(holdoutDecision.reasons)
if let variation = holdoutDecision.result {
// User is in holdout — return holdout variation with holdout info
let decision = DeliveryRuleDecision(variation: variation, skipToEveryoneElse: skipToEveryoneElse, holdout: holdout)
return DecisionResponse(result: decision, reasons: reasons)
}
Expand Down Expand Up @@ -848,7 +882,7 @@ class DefaultDecisionService: OPTDecisionService {
}

// MARK: - Utilities

/// Retrieves the bucketing ID for a user, defaulting to user ID unless overridden in attributes.
/// - Parameters:
/// - userId: The user's ID.
Expand Down
15 changes: 13 additions & 2 deletions Sources/Optimizely+Decide/OptimizelyClient+Decide.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2021-2022, Optimizely, Inc. and contributors
// Copyright 2021-2022, 2026, 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.
Expand Down Expand Up @@ -324,8 +324,19 @@ extension OptimizelyClient {
cmabUUID: flagDecision?.cmabUUID)
decisionEventDispatched = true
}
if let holdoutInfo = flagDecision?.holdoutDecision {
sendImpressionEvent(experiment: holdoutInfo.experiment,
variation: holdoutInfo.variation,
userId: userId,
attributes: attributes,
flagKey: feature.key,
ruleType: Constants.DecisionSource.holdout.rawValue,
enabled: false,
cmabUUID: nil)
decisionEventDispatched = true
}
}

var variableMap = [String: Any]()
if !allOptions.contains(.excludeVariables) {
let decisionResponse = getDecisionVariableMap(feature: feature,
Expand Down
10 changes: 9 additions & 1 deletion Sources/Utils/LogMessage.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2019-2021, 2023 Optimizely, Inc. and contributors
// Copyright 2019-2021, 2023, 2026 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.
Expand Down Expand Up @@ -79,6 +79,10 @@ enum LogMessage {
case cmabFetchSuccess(_ variationId: String, _ cmabUUID: String, _expKey: String)
case cmabFetchFailed(_ expKey: String)
case cmabNotSupportedInSyncMode
case holdoutExcludesTargetedDelivery(_ holdoutKey: String, _ ruleKey: String)
case holdoutExcludeTargetedDeliveriesEnabled(_ holdoutKey: String)
case userBucketedIntoRollout(_ userId: String, _ flagKey: String)
case userNotBucketedIntoRollout(_ userId: String, _ flagKey: String)
}

extension LogMessage: CustomStringConvertible {
Expand Down Expand Up @@ -152,6 +156,10 @@ extension LogMessage: CustomStringConvertible {
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."
case .holdoutExcludesTargetedDelivery(let holdoutKey, let ruleKey): message = "Holdout (\(holdoutKey)) excludes targeted deliveries, skipping holdout for rule (\(ruleKey))."
case .holdoutExcludeTargetedDeliveriesEnabled(let holdoutKey): message = "Holdout \"\(holdoutKey)\" has excludeTargetedDeliveries enabled, continuing to rollout evaluation."
case .userBucketedIntoRollout(let userId, let flagKey): message = "The user \"\(userId)\" was bucketed into a rollout for feature flag \"\(flagKey)\"."
case .userNotBucketedIntoRollout(let userId, let flagKey): message = "The user \"\(userId)\" was not bucketed into a rollout for feature flag \"\(flagKey)\"."
}

return message
Expand Down
Loading
Loading