diff --git a/Sources/eppo/Assignment.swift b/Sources/eppo/Assignment.swift index d75fd9c..9f24198 100644 --- a/Sources/eppo/Assignment.swift +++ b/Sources/eppo/Assignment.swift @@ -8,6 +8,7 @@ public class Assignment: CustomStringConvertible { public var subjectAttributes: SubjectAttributes public var metaData: [String: String] public var extraLogging: [String: String] + public var entityId: Int? public var description: String { return "Subject " + subject + " assigned to variation " + variation + " in experiment " + experiment @@ -21,7 +22,8 @@ public class Assignment: CustomStringConvertible { timestamp: String, subjectAttributes: SubjectAttributes, metaData: [String: String] = [:], - extraLogging: [String: String] = [:] + extraLogging: [String: String] = [:], + entityId: Int? = nil ) { self.allocation = allocationKey self.experiment = flagKey + "-" + allocationKey @@ -32,5 +34,6 @@ public class Assignment: CustomStringConvertible { self.subjectAttributes = subjectAttributes self.metaData = metaData self.extraLogging = extraLogging + self.entityId = entityId } } diff --git a/Sources/eppo/Constants.swift b/Sources/eppo/Constants.swift index 0634981..3649780 100644 --- a/Sources/eppo/Constants.swift +++ b/Sources/eppo/Constants.swift @@ -1,5 +1,5 @@ // todo: make this a build argument (FF-1944) public let sdkName = "ios" -public let sdkVersion = "4.3.0" +public let sdkVersion = "4.4.0" public let defaultHost = "https://fscdn.eppo.cloud/api" diff --git a/Sources/eppo/EppoClient.swift b/Sources/eppo/EppoClient.swift index 0aea86a..f45ff86 100644 --- a/Sources/eppo/EppoClient.swift +++ b/Sources/eppo/EppoClient.swift @@ -339,7 +339,8 @@ public class EppoClient { subjectKey: subjectKey, subjectAttributes: subjectAttributes, flagEvaluationCode: .flagUnrecognizedOrDisabled, - flagEvaluationDescription: "Unrecognized or disabled flag: \(flagKey)" + flagEvaluationDescription: "Unrecognized or disabled flag: \(flagKey)", + entityId: nil ) } @@ -360,7 +361,8 @@ public class EppoClient { flagEvaluationCode: .typeMismatch, flagEvaluationDescription: "Variation value does not have the correct type. Found \(flagConfig.variationType.rawValue.uppercased()), but expected \(expectedVariationType.rawValue.uppercased()) for flag \(flagKey)", unmatchedAllocations: [], - unevaluatedAllocations: allAllocations + unevaluatedAllocations: allAllocations, + entityId: flagConfig.entityId ) } @@ -391,6 +393,7 @@ public class EppoClient { } else { // Either the cache is not defined, or the assignment hasn't been logged yet // Perform assignment. + let entityId = flagEvaluation.entityId let assignment = Assignment( flagKey: flagKey, allocationKey: allocationKey, @@ -403,7 +406,8 @@ public class EppoClient { "sdkName": sdkName, "sdkVersion": sdkVersion ], - extraLogging: flagEvaluation.extraLogging + extraLogging: flagEvaluation.extraLogging, + entityId: entityId ) assignmentLogger(assignment) diff --git a/Sources/eppo/FlagEvaluation.swift b/Sources/eppo/FlagEvaluation.swift index 87378c8..97ddc07 100644 --- a/Sources/eppo/FlagEvaluation.swift +++ b/Sources/eppo/FlagEvaluation.swift @@ -15,6 +15,7 @@ public struct FlagEvaluation { let unevaluatedAllocations: [AllocationEvaluation] let flagEvaluationCode: EppoClient.FlagEvaluationCode let flagEvaluationDescription: String + let entityId: Int? static func matchedResult( flagKey: String, @@ -30,7 +31,8 @@ public struct FlagEvaluation { matchedAllocation: AllocationEvaluation? = nil, allocation: UFC_Allocation? = nil, unmatchedAllocations: [AllocationEvaluation] = [], - unevaluatedAllocations: [AllocationEvaluation] = [] + unevaluatedAllocations: [AllocationEvaluation] = [], + entityId: Int? = nil ) -> FlagEvaluation { // If the config is obfuscated, we need to unobfuscate the allocation key. var decodedAllocationKey: String = allocationKey ?? "" @@ -97,7 +99,8 @@ public struct FlagEvaluation { unmatchedAllocations: unmatchedAllocations, unevaluatedAllocations: unevaluatedAllocations, flagEvaluationCode: .match, - flagEvaluationDescription: flagEvaluationDescription + flagEvaluationDescription: flagEvaluationDescription, + entityId: entityId ) } @@ -108,7 +111,8 @@ public struct FlagEvaluation { flagEvaluationCode: EppoClient.FlagEvaluationCode = .flagUnrecognizedOrDisabled, flagEvaluationDescription: String? = nil, unmatchedAllocations: [AllocationEvaluation] = [], - unevaluatedAllocations: [AllocationEvaluation] = [] + unevaluatedAllocations: [AllocationEvaluation] = [], + entityId: Int? = nil ) -> FlagEvaluation { return FlagEvaluation( flagKey: flagKey, @@ -124,7 +128,8 @@ public struct FlagEvaluation { unmatchedAllocations: unmatchedAllocations, unevaluatedAllocations: unevaluatedAllocations, flagEvaluationCode: flagEvaluationCode, - flagEvaluationDescription: flagEvaluationDescription ?? "Unrecognized or disabled flag: \(flagKey)" + flagEvaluationDescription: flagEvaluationDescription ?? "Unrecognized or disabled flag: \(flagKey)", + entityId: entityId ) } } diff --git a/Sources/eppo/RuleEvaluator.swift b/Sources/eppo/RuleEvaluator.swift index 436bf6b..290a400 100644 --- a/Sources/eppo/RuleEvaluator.swift +++ b/Sources/eppo/RuleEvaluator.swift @@ -35,7 +35,8 @@ public class FlagEvaluator { return FlagEvaluation.noneResult( flagKey: flag.key, subjectKey: subjectKey, - subjectAttributes: subjectAttributes + subjectAttributes: subjectAttributes, + entityId: flag.entityId ) } @@ -44,7 +45,8 @@ public class FlagEvaluator { return FlagEvaluation.noneResult( flagKey: flag.key, subjectKey: subjectKey, - subjectAttributes: subjectAttributes + subjectAttributes: subjectAttributes, + entityId: flag.entityId ) } @@ -55,7 +57,8 @@ public class FlagEvaluator { subjectKey: subjectKey, subjectAttributes: subjectAttributes, flagEvaluationCode: .flagUnrecognizedOrDisabled, - flagEvaluationDescription: "Unrecognized or disabled flag: \(flag.key)" + flagEvaluationDescription: "Unrecognized or disabled flag: \(flag.key)", + entityId: flag.entityId ) return result } @@ -171,7 +174,8 @@ public class FlagEvaluator { unmatchedAllocations: unmatchedAllocations, unevaluatedAllocations: unevaluatedAllocations, flagEvaluationCode: .assignmentError, - flagEvaluationDescription: "Variation (\(variation.key)) is configured for type INTEGER, but is set to incompatible value (\(doubleValue))" + flagEvaluationDescription: "Variation (\(variation.key)) is configured for type INTEGER, but is set to incompatible value (\(doubleValue))", + entityId: flag.entityId ) return evaluation } @@ -194,7 +198,8 @@ public class FlagEvaluator { matchedAllocation: matchedAllocation, allocation: allocation, unmatchedAllocations: unmatchedAllocations, - unevaluatedAllocations: unevaluatedAllocations + unevaluatedAllocations: unevaluatedAllocations, + entityId: flag.entityId ) } @@ -225,7 +230,8 @@ public class FlagEvaluator { unmatchedAllocations: unmatchedAllocations, unevaluatedAllocations: unevaluatedAllocations, flagEvaluationCode: .assignmentError, - flagEvaluationDescription: "Variation (\(variation.key)) is configured for type INTEGER, but is set to incompatible value (\(doubleValue))" + flagEvaluationDescription: "Variation (\(variation.key)) is configured for type INTEGER, but is set to incompatible value (\(doubleValue))", + entityId: flag.entityId ) } // Create a new variation with the decoded value @@ -247,7 +253,8 @@ public class FlagEvaluator { matchedAllocation: matchedAllocation, allocation: allocation, unmatchedAllocations: unmatchedAllocations, - unevaluatedAllocations: unevaluatedAllocations + unevaluatedAllocations: unevaluatedAllocations, + entityId: flag.entityId ) } } @@ -265,7 +272,8 @@ public class FlagEvaluator { unmatchedAllocations: unmatchedAllocations, unevaluatedAllocations: unevaluatedAllocations, flagEvaluationCode: .assignmentError, - flagEvaluationDescription: "Variation (\(variation.key)) is configured for type INTEGER, but is set to incompatible value" + flagEvaluationDescription: "Variation (\(variation.key)) is configured for type INTEGER, but is set to incompatible value", + entityId: flag.entityId ) } else { return FlagEvaluation.noneResult( @@ -292,7 +300,8 @@ public class FlagEvaluator { matchedAllocation: matchedAllocation, allocation: allocation, unmatchedAllocations: unmatchedAllocations, - unevaluatedAllocations: unevaluatedAllocations + unevaluatedAllocations: unevaluatedAllocations, + entityId: flag.entityId ) } } @@ -311,7 +320,8 @@ public class FlagEvaluator { subjectKey: subjectKey, subjectAttributes: subjectAttributes, unmatchedAllocations: unmatchedAllocations, - unevaluatedAllocations: unevaluatedAllocations + unevaluatedAllocations: unevaluatedAllocations, + entityId: flag.entityId ) } diff --git a/Sources/eppo/UniversalFlagConfig.swift b/Sources/eppo/UniversalFlagConfig.swift index 6c557a3..354fd59 100644 --- a/Sources/eppo/UniversalFlagConfig.swift +++ b/Sources/eppo/UniversalFlagConfig.swift @@ -116,6 +116,7 @@ public struct UFC_Flag: Codable { let variations: [String: UFC_Variation] let allocations: [UFC_Allocation] let totalShards: Int + let entityId: Int? } public struct UFC_Variation: Codable { diff --git a/Tests/eppo/AssignmentLoggerTests.swift b/Tests/eppo/AssignmentLoggerTests.swift index d17c1f4..aeba131 100644 --- a/Tests/eppo/AssignmentLoggerTests.swift +++ b/Tests/eppo/AssignmentLoggerTests.swift @@ -14,6 +14,9 @@ final class AssignmentLoggerTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() + // Reset the shared instance to avoid test interference + EppoClient.resetSharedInstance() + let fileURL = Bundle.module.url( forResource: "Resources/test-data/ufc/flags-v1-obfuscated.json", withExtension: "" @@ -31,7 +34,9 @@ final class AssignmentLoggerTests: XCTestCase { // todo: do obfuscation and not tests. func testLogger() async throws { - eppoClient = try await EppoClient.initialize(sdkKey: "mock-api-key", assignmentLogger: loggerSpy.logger) + eppoClient = try await EppoClient.initialize(sdkKey: "mock-api-key", assignmentLogger: loggerSpy.logger, assignmentCache: nil) + + let assignment = try eppoClient.getNumericAssignment( flagKey: "numeric_flag", @@ -39,6 +44,7 @@ final class AssignmentLoggerTests: XCTestCase { subjectAttributes: SubjectAttributes(), defaultValue: 0) XCTAssertEqual(assignment, 3.1415926) + XCTAssertTrue(loggerSpy.wasCalled) if let lastAssignment = loggerSpy.lastAssignment { XCTAssertEqual(lastAssignment.allocation, "rollout") @@ -50,4 +56,423 @@ final class AssignmentLoggerTests: XCTestCase { XCTFail("No last assignment was logged.") } } + + func testHoldoutLoggingWithEntityIdAndHoldoutInfo() async throws { + // Create a test configuration with holdout information in extraLogging + let testJsonString = """ + { + "format": "SERVER", + "createdAt": "2024-04-17T19:40:53.716Z", + "environment": { + "name": "Test" + }, + "flags": { + "boolean-flag": { + "key": "boolean-flag", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "true": { + "key": "true", + "value": true + }, + "false": { + "key": "false", + "value": false + } + }, + "totalShards": 10000, + "entityId": 1, + "allocations": [ + { + "key": "allocation-84-short-term-holdout", + "startAt": "2025-07-18T20:09:55.084Z", + "endAt": "9999-12-31T00:00:00.000Z", + "splits": [ + { + "variationKey": "false", + "shards": [ + { + "salt": "boolean-flag-84-split", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + }, + { + "salt": "short-term-holdout-holdout-traffic", + "ranges": [ + { + "start": 0, + "end": 1000 + } + ] + }, + { + "salt": "short-term-holdout-holdout-split", + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + } + ], + "extraLogging": { + "holdoutKey": "short-term-holdout", + "holdoutVariation": "status_quo" + } + }, + { + "variationKey": "true", + "shards": [ + { + "salt": "boolean-flag-84-split", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + }, + { + "salt": "short-term-holdout-holdout-traffic", + "ranges": [ + { + "start": 0, + "end": 1000 + } + ] + }, + { + "salt": "short-term-holdout-holdout-split", + "ranges": [ + { + "start": 5000, + "end": 10000 + } + ] + } + ], + "extraLogging": { + "holdoutKey": "short-term-holdout", + "holdoutVariation": "all_shipped" + } + } + ], + "doLog": true + }, + { + "key": "allocation-84", + "startAt": "2025-07-18T20:09:55.084Z", + "endAt": "9999-12-31T00:00:00.000Z", + "splits": [ + { + "variationKey": "true", + "shards": [ + { + "salt": "boolean-flag-84-split", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "allocation-81", + "startAt": "2025-07-18T20:04:55.586Z", + "endAt": "9999-12-31T00:00:00.000Z", + "splits": [ + { + "variationKey": "false", + "shards": [] + } + ], + "doLog": true + } + ] + } + } + } + """ + + eppoClient = EppoClient.initializeOffline( + sdkKey: "mock-api-key", + assignmentLogger: loggerSpy.logger, + assignmentCache: nil, + initialConfiguration: try Configuration( + flagsConfigurationJson: Data(testJsonString.utf8), + obfuscated: false + ) + ) + + let assignment = try eppoClient.getBooleanAssignment( + flagKey: "boolean-flag", + subjectKey: "test-subject-9", + subjectAttributes: SubjectAttributes(), + defaultValue: false + ) + + // Verify the assignment was logged with holdout information + XCTAssertTrue(loggerSpy.wasCalled) + if let lastAssignment = loggerSpy.lastAssignment { + XCTAssertEqual(lastAssignment.entityId, 1) + XCTAssertEqual(lastAssignment.extraLogging, ["holdoutKey": "short-term-holdout", "holdoutVariation": "status_quo"]) + XCTAssertEqual(lastAssignment.featureFlag, "boolean-flag") + XCTAssertEqual(lastAssignment.allocation, "allocation-84-short-term-holdout") + XCTAssertEqual(lastAssignment.variation, "false") + XCTAssertEqual(lastAssignment.subject, "test-subject-9") + } else { + XCTFail("No last assignment was logged.") + } + } + + func testHoldoutLoggingWithoutEntityIdNorHoldoutInfo() async throws { + // Create a test configuration with entityId but without holdout information + let testJsonString = """ + { + "format": "SERVER", + "createdAt": "2024-04-17T19:40:53.716Z", + "environment": { + "name": "Test" + }, + "flags": { + "boolean-flag": { + "key": "boolean-flag", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "true": { + "key": "true", + "value": true + }, + "false": { + "key": "false", + "value": false + } + }, + "totalShards": 10000, + "allocations": [ + { + "key": "allocation-83-holdout-short-term-holdout", + "startAt": "2025-07-18T20:05:12.927Z", + "endAt": "9999-12-31T00:00:00.000Z", + "splits": [ + { + "variationKey": "false", + "shards": [ + { + "salt": "boolean-flag-83-split", + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + }, + { + "salt": "short-term-holdout-holdout-traffic", + "ranges": [ + { + "start": 0, + "end": 1000 + } + ] + } + ] + }, + { + "variationKey": "false", + "shards": [ + { + "salt": "boolean-flag-83-split", + "ranges": [ + { + "start": 5000, + "end": 10000 + } + ] + }, + { + "salt": "short-term-holdout-holdout-traffic", + "ranges": [ + { + "start": 0, + "end": 1000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "allocation-83", + "startAt": "2025-07-18T20:05:12.927Z", + "endAt": "9999-12-31T00:00:00.000Z", + "splits": [ + { + "variationKey": "true", + "shards": [ + { + "salt": "boolean-flag-83-split", + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "false", + "shards": [ + { + "salt": "boolean-flag-83-split", + "ranges": [ + { + "start": 5000, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "allocation-81", + "startAt": "2025-07-18T20:04:55.586Z", + "endAt": "9999-12-31T00:00:00.000Z", + "splits": [ + { + "variationKey": "false", + "shards": [] + } + ], + "doLog": true + } + ] + } + } + } + """ + + eppoClient = EppoClient.initializeOffline( + sdkKey: "mock-api-key", + assignmentLogger: loggerSpy.logger, + assignmentCache: nil, + initialConfiguration: try Configuration( + flagsConfigurationJson: Data(testJsonString.utf8), + obfuscated: false + ) + ) + + let assignment = try eppoClient.getBooleanAssignment( + flagKey: "boolean-flag", + subjectKey: "test-subject-9", + subjectAttributes: SubjectAttributes(), + defaultValue: false + ) + + // Verify the assignment was from a holdout, logged with entityId, but has no holdout information because + XCTAssertTrue(loggerSpy.wasCalled) + if let lastAssignment = loggerSpy.lastAssignment { + XCTAssertTrue(lastAssignment.extraLogging.isEmpty) + XCTAssertNil(lastAssignment.entityId) + XCTAssertEqual(lastAssignment.featureFlag, "boolean-flag") + XCTAssertEqual(lastAssignment.allocation, "allocation-83-holdout-short-term-holdout") + XCTAssertEqual(lastAssignment.variation, "false") + XCTAssertEqual(lastAssignment.subject, "test-subject-9") + } else { + XCTFail("No last assignment was logged.") + } + } + + func testHoldoutLoggingWithDoLogFalse() async throws { + // Create a test configuration with doLog: false + let testJsonString = """ + { + "format": "SERVER", + "createdAt": "2024-04-17T19:40:53.716Z", + "environment": { + "name": "Test" + }, + "flags": { + "boolean-flag": { + "key": "boolean-flag", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "true": { + "key": "true", + "value": true + }, + "false": { + "key": "false", + "value": false + } + }, + "totalShards": 10000, + "allocations": [ + { + "key": "allocation-no-logging", + "startAt": "2025-07-18T20:09:55.084Z", + "endAt": "9999-12-31T00:00:00.000Z", + "splits": [ + { + "variationKey": "true", + "shards": [ + { + "salt": "boolean-flag-no-logging-split", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": false + } + ] + } + } + } + """ + + eppoClient = EppoClient.initializeOffline( + sdkKey: "mock-api-key", + assignmentLogger: loggerSpy.logger, + assignmentCache: nil, + initialConfiguration: try Configuration( + flagsConfigurationJson: Data(testJsonString.utf8), + obfuscated: false + ) + ) + + let assignment = try eppoClient.getBooleanAssignment( + flagKey: "boolean-flag", + subjectKey: "test-subject-no-logging", + subjectAttributes: SubjectAttributes(), + defaultValue: false + ) + + // Verify the assignment was NOT logged because doLog: false + XCTAssertFalse(loggerSpy.wasCalled) + XCTAssertNil(loggerSpy.lastAssignment) + } } diff --git a/Tests/eppo/AssignmentTests.swift b/Tests/eppo/AssignmentTests.swift index 12a88f3..9946da6 100644 --- a/Tests/eppo/AssignmentTests.swift +++ b/Tests/eppo/AssignmentTests.swift @@ -37,4 +37,44 @@ final class AssignmentTests: XCTestCase { let expectedDescription = "Subject user456 assigned to variation variationA in experiment featureB-allocation2" XCTAssertEqual(assignment.description, expectedDescription) } + + func testAssignmentWithEntityId() { + let subjectAttributes = SubjectAttributes() + let entityId = 12345 + let assignment = Assignment( + flagKey: "featureC", + allocationKey: "allocation3", + variation: "variationB", + subject: "user789", + timestamp: "2024-03-21T12:34:56Z", + subjectAttributes: subjectAttributes, + entityId: entityId + ) + + XCTAssertTrue(assignment.extraLogging.isEmpty) + XCTAssertEqual(assignment.entityId, entityId) + XCTAssertEqual(assignment.featureFlag, "featureC") + XCTAssertEqual(assignment.allocation, "allocation3") + XCTAssertEqual(assignment.variation, "variationB") + XCTAssertEqual(assignment.subject, "user789") + } + + func testAssignmentWithHoldoutFields() { + let subjectAttributes = SubjectAttributes() + let assignment = Assignment( + flagKey: "featureD", + allocationKey: "allocation4", + variation: "variationC", + subject: "user101", + timestamp: "2024-03-22T12:34:56Z", + subjectAttributes: subjectAttributes, + extraLogging: ["holdoutKey": "holdout-xyz", "holdoutVariation": "status_quo"] + ) + + XCTAssertEqual(assignment.extraLogging, ["holdoutKey": "holdout-xyz", "holdoutVariation": "status_quo"]) + XCTAssertEqual(assignment.featureFlag, "featureD") + XCTAssertEqual(assignment.allocation, "allocation4") + XCTAssertEqual(assignment.variation, "variationC") + XCTAssertEqual(assignment.subject, "user101") + } } diff --git a/Tests/eppo/ConfigurationStoreTests.swift b/Tests/eppo/ConfigurationStoreTests.swift index ae9b071..d4f8e39 100644 --- a/Tests/eppo/ConfigurationStoreTests.swift +++ b/Tests/eppo/ConfigurationStoreTests.swift @@ -12,7 +12,8 @@ final class ConfigurationStoreTests: XCTestCase { variationType: UFC_VariationType.string, variations: [:], allocations: [], - totalShards: 0 + totalShards: 0, + entityId: nil ) override func setUpWithError() throws { diff --git a/Tests/eppo/RuleEvaluatorTests.swift b/Tests/eppo/RuleEvaluatorTests.swift index 99fc514..1618c72 100644 --- a/Tests/eppo/RuleEvaluatorTests.swift +++ b/Tests/eppo/RuleEvaluatorTests.swift @@ -28,7 +28,8 @@ final class flagEvaluatorTests: XCTestCase { variationType: UFC_VariationType.string, variations: [:], allocations: [], - totalShards: 100 + totalShards: 100, + entityId: nil ) override func setUpWithError() throws { @@ -315,7 +316,8 @@ final class flagEvaluatorTests: XCTestCase { doLog: true ) ], - totalShards: flag.totalShards + totalShards: flag.totalShards, + entityId: flag.entityId ) }