Skip to content

Add device_id as an automatic context value for Feature Flags #675

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
89 changes: 88 additions & 1 deletion MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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))
Expand Down Expand Up @@ -129,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
Expand Down Expand Up @@ -464,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() {
Expand Down Expand Up @@ -874,5 +918,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
42 changes: 40 additions & 2 deletions Sources/FeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
}

Expand Down Expand Up @@ -190,6 +191,11 @@ class FeatureFlagManager: Network, MixpanelFlags {
var isFetching: Bool = false
private var trackedFeatures: Set<String> = Set()
private var fetchCompletionHandlers: [(Bool) -> Void] = []

// Timing tracking properties
private var fetchStartTime: Date?
var timeLastFetched: Date?
var fetchLatencyMs: Int?

// Configuration
private var currentOptions: MixpanelOptions? { delegate?.getOptions() }
Expand Down Expand Up @@ -390,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.")
Expand All @@ -398,10 +410,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
Expand Down Expand Up @@ -443,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
}
Expand Down Expand Up @@ -488,9 +513,22 @@ 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",

var properties: Properties = [
"Experiment name": flagName,
"Variant name": variant.key,
"$experiment_type": "feature_flag",
]

// Add timing properties from the access queue
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
DispatchQueue.main.async {
delegate.track(event: "$experiment_started", properties: properties)
Expand Down
4 changes: 4 additions & 0 deletions Sources/MixpanelInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading