From 6d2f91c3e55db3bed7df5b779bef7b753fbe066c Mon Sep 17 00:00:00 2001 From: Kwame Efah Date: Fri, 19 Sep 2025 09:46:01 -0700 Subject: [PATCH 1/3] Plumb additional flags properties through to exposure event --- Sources/FeatureFlags.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index 23d11022..72660a4d 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -33,10 +33,14 @@ struct AnyCodable: Decodable { public struct MixpanelFlagVariant: Decodable { public let key: String // Corresponds to 'variant_key' from API public let value: Any? // Corresponds to 'variant_value' from API + public let isExperimentActive: Bool? // Corresponds to 'is_experiment_active' from API + public let isQATester: Bool? // Corresponds to 'is_qa_tester' from API enum CodingKeys: String, CodingKey { case key = "variant_key" case value = "variant_value" + case isExperimentActive = "is_experiment_active" + case isQATester = "is_qa_tester" } public init(from decoder: Decoder) throws { @@ -49,16 +53,22 @@ public struct MixpanelFlagVariant: Decodable { // If the value is an unsupported type, AnyCodable throws. let anyCodableValue = try container.decode(AnyCodable.self, forKey: .value) value = anyCodableValue.value // Extract the underlying Any? value + + // Decode optional boolean fields + isExperimentActive = try container.decodeIfPresent(Bool.self, forKey: .isExperimentActive) + isQATester = try container.decodeIfPresent(Bool.self, forKey: .isQATester) } // Helper initializer with fallbacks, value defaults to key if nil - public init(key: String = "", value: Any? = nil) { + public init(key: String = "", value: Any? = nil, isExperimentActive: Bool? = nil, isQATester: Bool? = nil) { self.key = key if let value = value { self.value = value } else { self.value = key } + self.isExperimentActive = isExperimentActive + self.isQATester = isQATester } } @@ -570,6 +580,13 @@ class FeatureFlagManager: Network, MixpanelFlags { properties["fetchLatencyMs"] = fetchLatencyMs } + if let isExperimentActive = variant.isExperimentActive { + properties["isExperimentActive"] = isExperimentActive + } + if let isQATester = variant.isQATester { + properties["isQATester"] = isQATester + } + // Dispatch delegate call asynchronously to main thread for safety DispatchQueue.main.async { delegate.track(event: "$experiment_started", properties: properties) From ed639a153ed5bd55658e7277fc8de8ac175d65f1 Mon Sep 17 00:00:00 2001 From: Kwame Efah Date: Mon, 22 Sep 2025 09:14:30 -0700 Subject: [PATCH 2/3] Add experiment ID decoding and tests --- .../MixpanelFeatureFlagTests.swift | 67 +++++++++++++++++++ Sources/FeatureFlags.swift | 11 ++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index 466ad182..5a065c13 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -882,6 +882,56 @@ class FeatureFlagManagerTests: XCTestCase { let missingFieldResult = parseResponse(missingFieldJSON) XCTAssertNotNil(missingFieldResult, "Parser should handle missing flags field") XCTAssertNil(missingFieldResult?.flags, "Flags should be nil when field is missing") + + let optionalExperimentPropertiesJSON = """ + { + "flags": { + "active_experiment_flag": { + "variant_key": "A", + "variant_value": "A", + "experiment_id": "447db52b-ec4a-4186-8d89-f9ba7bc7d7dd", + "is_experiment_active": true, + "is_qa_tester": false + }, + "experiment_flag_for_qa_user": { + "variant_key": "B", + "variant_value": "B", + "experiment_id": "447db52b-ec4a-4186-8d89-f9ba7bc7d7dd", + "is_experiment_active": false, + "is_qa_tester": true + }, + "flag_with_no_optionals": { + "variant_key": "C", + "variant_value": "C" + } + } + } + """.data(using: .utf8)! + + let experimentResult = parseResponse(optionalExperimentPropertiesJSON) + XCTAssertNotNil(experimentResult) + XCTAssertEqual(experimentResult?.flags?.count, 3) + + let activeFlag = experimentResult?.flags?["active_experiment_flag"] + XCTAssertEqual(activeFlag?.key, "A") + XCTAssertEqual(activeFlag?.value as? String, "A") + XCTAssertEqual(activeFlag?.experimentID, "447db52b-ec4a-4186-8d89-f9ba7bc7d7dd") + XCTAssertEqual(activeFlag?.isExperimentActive, true) + XCTAssertEqual(activeFlag?.isQATester, false) + + let qaFlag = experimentResult?.flags?["experiment_flag_for_qa_user"] + XCTAssertEqual(qaFlag?.key, "B") + XCTAssertEqual(qaFlag?.value as? String, "B") + XCTAssertEqual(qaFlag?.experimentID, "447db52b-ec4a-4186-8d89-f9ba7bc7d7dd") + XCTAssertEqual(qaFlag?.isExperimentActive, false) + XCTAssertEqual(qaFlag?.isQATester, true) + + let minimalFlag = experimentResult?.flags?["flag_with_no_optionals"] + XCTAssertEqual(minimalFlag?.key, "C") + XCTAssertEqual(minimalFlag?.value as? String, "C") + XCTAssertNil(minimalFlag?.experimentID) + XCTAssertNil(minimalFlag?.isExperimentActive) + XCTAssertNil(minimalFlag?.isQATester) } // --- Delegate Error Handling Tests --- @@ -1258,6 +1308,23 @@ class FeatureFlagManagerTests: XCTestCase { } } + func testTrackingIncludesOptionalProperties() { + // Set up flags with experiment properties + let flagsWithExperiment: [String: MixpanelFlagVariant] = [ + "experiment_flag": MixpanelFlagVariant(key: "variant_a", value: true, isExperimentActive: true, isQATester: false, experimentID: "exp_123") + ] + simulateFetchSuccess(flags: flagsWithExperiment) + + mockDelegate.trackExpectation = XCTestExpectation(description: "Track with experiment properties") + _ = manager.getVariantSync("experiment_flag", fallback: defaultFallback) + wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) + + let props = mockDelegate.trackedEvents[0].properties! + XCTAssertEqual(props["experimentID"] as? String, "exp_123") + XCTAssertEqual(props["isExperimentActive"] as? Bool, true) + XCTAssertEqual(props["isQATester"] as? Bool, false) + } + // MARK: - Timing Properties Sanity Tests func testTimingPropertiesSanity() { diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index 72660a4d..b6294736 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -33,12 +33,14 @@ struct AnyCodable: Decodable { public struct MixpanelFlagVariant: Decodable { public let key: String // Corresponds to 'variant_key' from API public let value: Any? // Corresponds to 'variant_value' from API + public let experimentID: String? // Corresponds to 'experiment_id' from API public let isExperimentActive: Bool? // Corresponds to 'is_experiment_active' from API public let isQATester: Bool? // Corresponds to 'is_qa_tester' from API enum CodingKeys: String, CodingKey { case key = "variant_key" case value = "variant_value" + case experimentID = "experiment_id" case isExperimentActive = "is_experiment_active" case isQATester = "is_qa_tester" } @@ -54,19 +56,21 @@ public struct MixpanelFlagVariant: Decodable { let anyCodableValue = try container.decode(AnyCodable.self, forKey: .value) value = anyCodableValue.value // Extract the underlying Any? value - // Decode optional boolean fields + // Decode optional fields for tracking + experimentID = try container.decodeIfPresent(String.self, forKey: .experimentID) isExperimentActive = try container.decodeIfPresent(Bool.self, forKey: .isExperimentActive) isQATester = try container.decodeIfPresent(Bool.self, forKey: .isQATester) } // Helper initializer with fallbacks, value defaults to key if nil - public init(key: String = "", value: Any? = nil, isExperimentActive: Bool? = nil, isQATester: Bool? = nil) { + public init(key: String = "", value: Any? = nil, isExperimentActive: Bool? = nil, isQATester: Bool? = nil, experimentID: String? = nil) { self.key = key if let value = value { self.value = value } else { self.value = key } + self.experimentID = experimentID self.isExperimentActive = isExperimentActive self.isQATester = isQATester } @@ -580,6 +584,9 @@ class FeatureFlagManager: Network, MixpanelFlags { properties["fetchLatencyMs"] = fetchLatencyMs } + if let experimentID = variant.experimentID { + properties["experimentID"] = experimentID + } if let isExperimentActive = variant.isExperimentActive { properties["isExperimentActive"] = isExperimentActive } From 4f6fda7ce9807454b4a042109bedd0d4687b1d8a Mon Sep 17 00:00:00 2001 From: Kwame Efah Date: Tue, 23 Sep 2025 09:27:08 -0700 Subject: [PATCH 3/3] Update tracking prop keys --- .../MixpanelDemoTests/MixpanelFeatureFlagTests.swift | 6 +++--- Sources/FeatureFlags.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index 5a065c13..0e142e29 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -1320,9 +1320,9 @@ class FeatureFlagManagerTests: XCTestCase { wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) let props = mockDelegate.trackedEvents[0].properties! - XCTAssertEqual(props["experimentID"] as? String, "exp_123") - XCTAssertEqual(props["isExperimentActive"] as? Bool, true) - XCTAssertEqual(props["isQATester"] as? Bool, false) + XCTAssertEqual(props["$experiment_id"] as? String, "exp_123") + XCTAssertEqual(props["$is_experiment_active"] as? Bool, true) + XCTAssertEqual(props["$is_qa_tester"] as? Bool, false) } // MARK: - Timing Properties Sanity Tests diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index b6294736..ec91df94 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -585,13 +585,13 @@ class FeatureFlagManager: Network, MixpanelFlags { } if let experimentID = variant.experimentID { - properties["experimentID"] = experimentID + properties["$experiment_id"] = experimentID } if let isExperimentActive = variant.isExperimentActive { - properties["isExperimentActive"] = isExperimentActive + properties["$is_experiment_active"] = isExperimentActive } if let isQATester = variant.isQATester { - properties["isQATester"] = isQATester + properties["$is_qa_tester"] = isQATester } // Dispatch delegate call asynchronously to main thread for safety