From 89e9c361ec81e8b6b39c876f36c8e74525afaed9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Jul 2025 01:23:26 +0000 Subject: [PATCH 1/6] Initial plan From 7be2e9e59214428cb84aa3799545b20dbf401274 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Jul 2025 01:32:20 +0000 Subject: [PATCH 2/6] Add device_id as automatic context value for Feature Flags Co-authored-by: msiebert <1504059+msiebert@users.noreply.github.com> --- .../MixpanelFeatureFlagTests.swift | 54 ++++++++++++++++++- Sources/FeatureFlags.swift | 5 ++ Sources/MixpanelInstance.swift | 4 ++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index 47f0e8c8..4b7f7242 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -16,17 +16,21 @@ class MockFeatureFlagDelegate: MixpanelFlagDelegate { var options: MixpanelOptions var distinctId: String + var anonymousId: String? var trackedEvents: [(event: String?, properties: Properties?)] = [] var trackExpectation: XCTestExpectation? var getOptionsCallCount = 0 var getDistinctIdCallCount = 0 + var getAnonymousIdCallCount = 0 init( options: MixpanelOptions = MixpanelOptions(token: "test", featureFlagsEnabled: true), - distinctId: String = "test_distinct_id" + distinctId: String = "test_distinct_id", + anonymousId: String? = "test_anonymous_id" ) { self.options = options self.distinctId = distinctId + self.anonymousId = anonymousId } func getOptions() -> MixpanelOptions { @@ -39,6 +43,11 @@ class MockFeatureFlagDelegate: MixpanelFlagDelegate { return distinctId } + func getAnonymousId() -> String? { + getAnonymousIdCallCount += 1 + return anonymousId + } + func track(event: String?, properties: Properties?) { print("MOCK Delegate: Track called - Event: \(event ?? "nil"), Props: \(properties ?? [:])") trackedEvents.append((event: event, properties: properties)) @@ -874,5 +883,48 @@ class FeatureFlagManagerTests: XCTestCase { XCTAssertTrue(error is DecodingError, "Error should be a DecodingError") } } + + func testFeatureFlagContextIncludesDeviceId() { + // Test that device_id is included in the feature flags context + let testAnonymousId = "test_device_id_12345" + let testDistinctId = "test_distinct_id_67890" + + let mockDelegate = MockFeatureFlagDelegate( + options: MixpanelOptions(token: "test", featureFlagsEnabled: true), + distinctId: testDistinctId, + anonymousId: testAnonymousId + ) + + let manager = FeatureFlagManager(serverURL: "https://test.com", delegate: mockDelegate) + + // Verify the delegate methods return expected values + XCTAssertEqual(mockDelegate.getDistinctId(), testDistinctId) + XCTAssertEqual(mockDelegate.getAnonymousId(), testAnonymousId) + + // Verify call counts + XCTAssertEqual(mockDelegate.getDistinctIdCallCount, 1) + XCTAssertEqual(mockDelegate.getAnonymousIdCallCount, 1) + } + + func testFeatureFlagContextWithNilAnonymousId() { + // Test that device_id is not included when anonymous ID is nil + let testDistinctId = "test_distinct_id_67890" + + let mockDelegate = MockFeatureFlagDelegate( + options: MixpanelOptions(token: "test", featureFlagsEnabled: true), + distinctId: testDistinctId, + anonymousId: nil + ) + + let manager = FeatureFlagManager(serverURL: "https://test.com", delegate: mockDelegate) + + // Verify the delegate methods return expected values + XCTAssertEqual(mockDelegate.getDistinctId(), testDistinctId) + XCTAssertNil(mockDelegate.getAnonymousId()) + + // Verify call counts + XCTAssertEqual(mockDelegate.getDistinctIdCallCount, 1) + XCTAssertEqual(mockDelegate.getAnonymousIdCallCount, 1) + } } // End Test Class diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index 5887ddcc..f03ca886 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -71,6 +71,7 @@ struct FlagsResponse: Decodable { public protocol MixpanelFlagDelegate: AnyObject { func getOptions() -> MixpanelOptions func getDistinctId() -> String + func getAnonymousId() -> String? func track(event: String?, properties: Properties?) } @@ -398,10 +399,14 @@ class FeatureFlagManager: Network, MixpanelFlags { } let distinctId = delegate.getDistinctId() + let anonymousId = delegate.getAnonymousId() print("Fetching flags for distinct ID: \(distinctId)") var context = options.featureFlagsContext context["distinct_id"] = distinctId + if let anonymousId = anonymousId { + context["device_id"] = anonymousId + } let requestBodyDict = ["context": context] guard diff --git a/Sources/MixpanelInstance.swift b/Sources/MixpanelInstance.swift index cbe28a55..2f1b993e 100644 --- a/Sources/MixpanelInstance.swift +++ b/Sources/MixpanelInstance.swift @@ -462,6 +462,10 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele return distinctId } + public func getAnonymousId() -> String? { + return anonymousId + } + #if !os(OSX) && !os(watchOS) private func setupListeners() { let notificationCenter = NotificationCenter.default From f437b5e8acf65800f94172b93159808ceebaabdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Jul 2025 22:32:21 +0000 Subject: [PATCH 3/6] Add timeLastFetched and fetchLatencyMs tracking properties to $experiment_started events Co-authored-by: msiebert <1504059+msiebert@users.noreply.github.com> --- .../MixpanelFeatureFlagTests.swift | 35 +++++++++++++++ Sources/FeatureFlags.swift | 45 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index 4b7f7242..6e229bba 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -138,9 +138,13 @@ class FeatureFlagManagerTests: XCTestCase { private func simulateFetchSuccess(flags: [String: MixpanelFlagVariant]? = nil) { let flagsToSet = flags ?? sampleFlags + let currentTime = Date() // Set flags directly *before* calling completeFetch manager.accessQueue.sync { manager.flags = flagsToSet + // Set timing properties to simulate a successful fetch + manager.timeLastFetched = currentTime + manager.fetchLatencyMs = 150 // Simulate 150ms fetch time // Important: Set isFetching = true *before* calling _completeFetch, // as _completeFetch assumes a fetch was in progress. manager.isFetching = true @@ -473,6 +477,37 @@ class FeatureFlagManagerTests: XCTestCase { AssertEqual(props["Experiment name"] ?? nil, "feature_int") AssertEqual(props["Variant name"] ?? nil, "v_int") AssertEqual(props["$experiment_type"] ?? nil, "feature_flag") + + // Check timing properties are included (values may be nil if not set) + XCTAssertTrue(props.keys.contains("timeLastFetched"), "Should include timeLastFetched property") + XCTAssertTrue(props.keys.contains("fetchLatencyMs"), "Should include fetchLatencyMs property") + } + + func testTracking_IncludesTimingProperties() { + simulateFetchSuccess() + mockDelegate.trackExpectation = XCTestExpectation( + description: "Track called with timing properties") + + _ = manager.getVariantSync("feature_string", fallback: defaultFallback) // Trigger tracking + + wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) + + XCTAssertEqual(mockDelegate.trackedEvents.count, 1) + let tracked = mockDelegate.trackedEvents[0] + let props = tracked.properties! + + // Verify timing properties have expected values + if let timeLastFetched = props["timeLastFetched"] as? Int { + XCTAssertGreaterThan(timeLastFetched, 0, "timeLastFetched should be a positive timestamp") + } else { + XCTFail("timeLastFetched should be present and be an Int") + } + + if let fetchLatencyMs = props["fetchLatencyMs"] as? Int { + XCTAssertEqual(fetchLatencyMs, 150, "fetchLatencyMs should match simulated value") + } else { + XCTFail("fetchLatencyMs should be present and be an Int") + } } func testTracking_DoesNotTrackForFallback_Sync() { diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index f03ca886..6229a0d5 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -191,6 +191,11 @@ class FeatureFlagManager: Network, MixpanelFlags { var isFetching: Bool = false private var trackedFeatures: Set = Set() private var fetchCompletionHandlers: [(Bool) -> Void] = [] + + // Timing tracking properties + private var fetchStartTime: Date? + private var timeLastFetched: Date? + private var fetchLatencyMs: Int? // Configuration private var currentOptions: MixpanelOptions? { delegate?.getOptions() } @@ -391,6 +396,12 @@ class FeatureFlagManager: Network, MixpanelFlags { // Performs the actual network request construction and call private func _performFetchRequest() { // This method runs OUTSIDE the accessQueue + + // Record fetch start time + let startTime = Date() + accessQueue.async { [weak self] in + self?.fetchStartTime = startTime + } guard let delegate = self.delegate, let options = self.currentOptions else { print("Error: Delegate or options missing for fetch.") @@ -448,11 +459,20 @@ class FeatureFlagManager: Network, MixpanelFlags { success: { [weak self] (flagsResponse, response) in // Completion handlers run on URLSession's queue print("Successfully fetched flags.") guard let self = self else { return } + let fetchEndTime = Date() // Update state and call completions via _completeFetch on the serial queue self.accessQueue.async { [weak self] in guard let self = self else { return } // already on accessQueue – write directly self.flags = flagsResponse.flags ?? [:] + + // Calculate timing metrics + if let startTime = self.fetchStartTime { + let latencyMs = Int(fetchEndTime.timeIntervalSince(startTime) * 1000) + self.fetchLatencyMs = latencyMs + } + self.timeLastFetched = fetchEndTime + print("Flags updated: \(self.flags ?? [:])") self._completeFetch(success: true) // still on accessQueue } @@ -493,9 +513,30 @@ class FeatureFlagManager: Network, MixpanelFlags { // Helper to just call the delegate (no locking) private func _performTrackingDelegateCall(flagName: String, variant: MixpanelFlagVariant) { guard let delegate = self.delegate else { return } - let properties: Properties = [ - "Experiment name": flagName, "Variant name": variant.key, "$experiment_type": "feature_flag", + + // Get timing properties from the access queue + var timingProperties: [String: Any] = [:] + accessQueue.sync { + if let timeLastFetched = self.timeLastFetched { + // Convert to Unix timestamp in seconds + timingProperties["timeLastFetched"] = Int(timeLastFetched.timeIntervalSince1970) + } + if let fetchLatencyMs = self.fetchLatencyMs { + timingProperties["fetchLatencyMs"] = fetchLatencyMs + } + } + + var properties: Properties = [ + "Experiment name": flagName, + "Variant name": variant.key, + "$experiment_type": "feature_flag", ] + + // Add timing properties + for (key, value) in timingProperties { + properties[key] = value + } + // Dispatch delegate call asynchronously to main thread for safety DispatchQueue.main.async { delegate.track(event: "$experiment_started", properties: properties) From 516fc4b3346c8d2123409ac849a71332706406a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Jul 2025 23:28:07 +0000 Subject: [PATCH 4/6] Fix type mismatch in timeLastFetched and fetchLatencyMs properties Co-authored-by: msiebert <1504059+msiebert@users.noreply.github.com> --- Sources/FeatureFlags.swift | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index 6229a0d5..031571b2 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -514,27 +514,21 @@ class FeatureFlagManager: Network, MixpanelFlags { private func _performTrackingDelegateCall(flagName: String, variant: MixpanelFlagVariant) { guard let delegate = self.delegate else { return } - // Get timing properties from the access queue - var timingProperties: [String: Any] = [:] - accessQueue.sync { - if let timeLastFetched = self.timeLastFetched { - // Convert to Unix timestamp in seconds - timingProperties["timeLastFetched"] = Int(timeLastFetched.timeIntervalSince1970) - } - if let fetchLatencyMs = self.fetchLatencyMs { - timingProperties["fetchLatencyMs"] = fetchLatencyMs - } - } - var properties: Properties = [ "Experiment name": flagName, "Variant name": variant.key, "$experiment_type": "feature_flag", ] - // Add timing properties - for (key, value) in timingProperties { - properties[key] = value + // Add timing properties from the access queue + accessQueue.sync { + if let timeLastFetched = self.timeLastFetched { + // Convert to Unix timestamp in seconds + properties["timeLastFetched"] = Int(timeLastFetched.timeIntervalSince1970) + } + if let fetchLatencyMs = self.fetchLatencyMs { + properties["fetchLatencyMs"] = fetchLatencyMs + } } // Dispatch delegate call asynchronously to main thread for safety From f102150afa76d195a83bb54302f251a8e85521d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:11:36 +0000 Subject: [PATCH 5/6] Fix private access level for timeLastFetched and fetchLatencyMs properties Co-authored-by: msiebert <1504059+msiebert@users.noreply.github.com> --- Sources/FeatureFlags.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index 031571b2..034cc44a 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -194,8 +194,8 @@ class FeatureFlagManager: Network, MixpanelFlags { // Timing tracking properties private var fetchStartTime: Date? - private var timeLastFetched: Date? - private var fetchLatencyMs: Int? + var timeLastFetched: Date? + var fetchLatencyMs: Int? // Configuration private var currentOptions: MixpanelOptions? { delegate?.getOptions() } From 2252516377a32f278b378f4f0300d96815630842 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Mon, 14 Jul 2025 11:38:14 -0700 Subject: [PATCH 6/6] fix concurrency bug --- Sources/FeatureFlags.swift | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index 034cc44a..3c275ad0 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -521,14 +521,12 @@ class FeatureFlagManager: Network, MixpanelFlags { ] // Add timing properties from the access queue - accessQueue.sync { - if let timeLastFetched = self.timeLastFetched { - // Convert to Unix timestamp in seconds - properties["timeLastFetched"] = Int(timeLastFetched.timeIntervalSince1970) - } - if let fetchLatencyMs = self.fetchLatencyMs { - properties["fetchLatencyMs"] = fetchLatencyMs - } + if let timeLastFetched = self.timeLastFetched { + // Convert to Unix timestamp in seconds + properties["timeLastFetched"] = Int(timeLastFetched.timeIntervalSince1970) + } + if let fetchLatencyMs = self.fetchLatencyMs { + properties["fetchLatencyMs"] = fetchLatencyMs } // Dispatch delegate call asynchronously to main thread for safety