From e073203475eb15f1f38f2018cae5850dc4ac7e7d Mon Sep 17 00:00:00 2001 From: Kwame Efah Date: Mon, 22 Sep 2025 09:53:58 -0700 Subject: [PATCH 1/3] Add experiment properties to feature flag exposure tracking --- .../mpmetrics/FeatureFlagManagerTest.java | 46 ++++++++++++++++++ .../android/mpmetrics/FeatureFlagManager.java | 10 ++++ .../mpmetrics/MixpanelFlagVariant.java | 48 +++++++++++++++++++ .../com/mixpanel/android/util/JsonUtils.java | 18 ++++++- .../mixpanel/android/util/MPConstants.java | 3 ++ 5 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java index 60b535773..df6ee072d 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java @@ -303,6 +303,18 @@ private String createFlagsResponseJson(Map flags) { } else { flagDef.put("variant_value", entry.getValue().value); } + + // Add optional experiment parameters if they exist + if (entry.getValue().experimentID != null) { + flagDef.put("experiment_id", entry.getValue().experimentID); + } + if (entry.getValue().isExperimentActive != null) { + flagDef.put("is_experiment_active", entry.getValue().isExperimentActive); + } + if (entry.getValue().isQATester != null) { + flagDef.put("is_qa_tester", entry.getValue().isQATester); + } + flagsObject.put(entry.getKey(), flagDef); } return new JSONObject().put("flags", flagsObject).toString(); @@ -1701,4 +1713,38 @@ public void testTimingUpperBoundValidation() throws InterruptedException, JSONEx // In practice, our test should complete much faster assertTrue("Test fetch should be fast in practice", fetchLatencyMs < 5000); } + + @Test + public void testOptionalParametersInTracking_WithAllFields_ShouldIncludeInProperties() throws Exception { + // Create flag variant with all experiment parameters + Map serverFlags = new HashMap<>(); + serverFlags.put("test_tracking_flag", new MixpanelFlagVariant("variant_c", "tracking_value", "exp_789", true, true)); + + mMockRemoteService.addResponse(createFlagsResponseJson(serverFlags).getBytes(StandardCharsets.UTF_8)); + mFeatureFlagManager.loadFlags(); + + // Wait for flags to be ready + for (int i = 0; i < 20 && !mFeatureFlagManager.areFlagsReady(); ++i) { + Thread.sleep(100); + } + assertTrue("Flags should be ready", mFeatureFlagManager.areFlagsReady()); + + // Setup tracking expectation + mMockDelegate.resetTrackCalls(); + mMockDelegate.trackCalledLatch = new CountDownLatch(1); + + // Get variant to trigger tracking + MixpanelFlagVariant fallback = new MixpanelFlagVariant("fallback", "fallback_value"); + mFeatureFlagManager.getVariantSync("test_tracking_flag", fallback); + + // Wait for tracking call + assertTrue("Track should be called", mMockDelegate.trackCalledLatch.await(5, TimeUnit.SECONDS)); + assertEquals("Track should be called exactly once", 1, mMockDelegate.trackCalls.size()); + + // Verify tracking properties include optional parameters + MockFeatureFlagDelegate.TrackCall call = mMockDelegate.trackCalls.get(0); + assertEquals("ExperimentID should be included", "exp_789", call.properties.getString("experimentID")); + assertTrue("IsExperimentActive should be included", call.properties.getBoolean("isExperimentActive")); + assertTrue("IsQATester should be included", call.properties.getBoolean("isQATester")); + } } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java index f6548c5c4..d993afdc5 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java @@ -711,6 +711,16 @@ private void _performTrackingDelegateCall(String flagName, MixpanelFlagVariant v properties.put("timeLastFetched", timing.timeLastFetched); properties.put("fetchLatencyMs", timing.fetchLatencyMs); } + + if (variant.experimentID != null) { + properties.put("experimentID", variant.experimentID); + } + if (variant.isExperimentActive != null) { + properties.put("isExperimentActive", variant.isExperimentActive); + } + if (variant.isQATester != null) { + properties.put("isQATester", variant.isQATester); + } } catch (JSONException e) { MPLog.e(LOGTAG, "Failed to create JSON properties for $experiment_started event", e); return; // Don't track if properties failed diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java index cc84b6f65..48c598505 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java @@ -25,6 +25,24 @@ public class MixpanelFlagVariant { @Nullable public final Object value; + /** + * The value of experimentID. This corresponds to the optional 'experiment_id' field in the Mixpanel API response. + */ + @Nullable + public final String experimentID; + + /** + * The value of isExperimentActive. This corresponds to the optional 'is_experiment_active' field in the Mixpanel API response. + */ + @Nullable + public final Boolean isExperimentActive; + + /** + * The value of isQATester. This corresponds to the optional 'is_qa_tester' field in the Mixpanel API response. + */ + @Nullable + public final Boolean isQATester; + /** * Constructs a {@code FeatureFlagData} object when parsing an API response. * @@ -35,6 +53,27 @@ public class MixpanelFlagVariant { public MixpanelFlagVariant(@NonNull String key, @Nullable Object value) { this.key = key; this.value = value; + this.experimentID = null; + this.isExperimentActive = null; + this.isQATester = null; + } + + /** + * Constructs a {@code FeatureFlagData} object when parsing an API response with optional experiment fields. + * + * @param key The key of the feature flag variant. Corresponds to 'variant_key' from the API. Cannot be null. + * @param value The value of the feature flag variant. Corresponds to 'variant_value' from the API. + * Can be Boolean, String, Number, JSONArray, JSONObject, or null. + * @param experimentID The experiment ID. Corresponds to 'experiment_id' from the API. Can be null. + * @param isExperimentActive Whether the experiment is active. Corresponds to 'is_experiment_active' from the API. Can be null. + * @param isQATester Whether the user is a QA tester. Corresponds to 'is_qa_tester' from the API. Can be null. + */ + public MixpanelFlagVariant(@NonNull String key, @Nullable Object value, @Nullable String experimentID, @Nullable Boolean isExperimentActive, @Nullable Boolean isQATester) { + this.key = key; + this.value = value; + this.experimentID = experimentID; + this.isExperimentActive = isExperimentActive; + this.isQATester = isQATester; } /** @@ -48,6 +87,9 @@ public MixpanelFlagVariant(@NonNull String key, @Nullable Object value) { public MixpanelFlagVariant(@NonNull String keyAndValue) { this.key = keyAndValue; // Default key to the value itself this.value = keyAndValue; + this.experimentID = null; + this.isExperimentActive = null; + this.isQATester = null; } /** @@ -62,6 +104,9 @@ public MixpanelFlagVariant(@NonNull String keyAndValue) { public MixpanelFlagVariant(@NonNull Object value) { this.key = ""; this.value = value; + this.experimentID = null; + this.isExperimentActive = null; + this.isQATester = null; } /** @@ -72,5 +117,8 @@ public MixpanelFlagVariant(@NonNull Object value) { MixpanelFlagVariant() { this.key = ""; this.value = null; + this.experimentID = null; + this.isExperimentActive = null; + this.isQATester = null; } } \ No newline at end of file diff --git a/src/main/java/com/mixpanel/android/util/JsonUtils.java b/src/main/java/com/mixpanel/android/util/JsonUtils.java index 1ea098145..7393fab8f 100644 --- a/src/main/java/com/mixpanel/android/util/JsonUtils.java +++ b/src/main/java/com/mixpanel/android/util/JsonUtils.java @@ -153,7 +153,23 @@ public static Map parseFlagsResponse(@Nullable JSON MPLog.w(LOGTAG, "Flag definition missing 'variant_value' for key: " + featureName + ". Assuming null value."); } - MixpanelFlagVariant flagData = new MixpanelFlagVariant(variantKey, variantValue); + // Parse optional experiment tracking fields + String experimentID = null; + if (flagDefinition.has(MPConstants.Flags.EXPERIMENT_ID) && !flagDefinition.isNull(MPConstants.Flags.EXPERIMENT_ID)) { + experimentID = flagDefinition.getString(MPConstants.Flags.EXPERIMENT_ID); + } + + Boolean isExperimentActive = null; + if (flagDefinition.has(MPConstants.Flags.IS_EXPERIMENT_ACTIVE) && !flagDefinition.isNull(MPConstants.Flags.IS_EXPERIMENT_ACTIVE)) { + isExperimentActive = flagDefinition.getBoolean(MPConstants.Flags.IS_EXPERIMENT_ACTIVE); + } + + Boolean isQATester = null; + if (flagDefinition.has(MPConstants.Flags.IS_QA_TESTER) && !flagDefinition.isNull(MPConstants.Flags.IS_QA_TESTER)) { + isQATester = flagDefinition.getBoolean(MPConstants.Flags.IS_QA_TESTER); + } + + MixpanelFlagVariant flagData = new MixpanelFlagVariant(variantKey, variantValue, experimentID, isExperimentActive, isQATester); flagsMap.put(featureName, flagData); } catch (JSONException e) { diff --git a/src/main/java/com/mixpanel/android/util/MPConstants.java b/src/main/java/com/mixpanel/android/util/MPConstants.java index dbe336464..576bed104 100644 --- a/src/main/java/com/mixpanel/android/util/MPConstants.java +++ b/src/main/java/com/mixpanel/android/util/MPConstants.java @@ -21,5 +21,8 @@ public static class Flags { public static final String FLAGS_KEY = "flags"; public static final String VARIANT_KEY = "variant_key"; public static final String VARIANT_VALUE = "variant_value"; + public static final String EXPERIMENT_ID = "experiment_id"; + public static final String IS_EXPERIMENT_ACTIVE = "is_experiment_active"; + public static final String IS_QA_TESTER = "is_qa_tester"; } } From 14884aba8bb710cc018c852ffd713ecd97015394 Mon Sep 17 00:00:00 2001 From: Kwame Efah Date: Tue, 23 Sep 2025 09:24:10 -0700 Subject: [PATCH 2/3] rename tracking prop --- .../mixpanel/android/mpmetrics/FeatureFlagManagerTest.java | 6 +++--- .../com/mixpanel/android/mpmetrics/FeatureFlagManager.java | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java b/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java index df6ee072d..f9a72ae8f 100644 --- a/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java +++ b/src/androidTest/java/com/mixpanel/android/mpmetrics/FeatureFlagManagerTest.java @@ -1743,8 +1743,8 @@ public void testOptionalParametersInTracking_WithAllFields_ShouldIncludeInProper // Verify tracking properties include optional parameters MockFeatureFlagDelegate.TrackCall call = mMockDelegate.trackCalls.get(0); - assertEquals("ExperimentID should be included", "exp_789", call.properties.getString("experimentID")); - assertTrue("IsExperimentActive should be included", call.properties.getBoolean("isExperimentActive")); - assertTrue("IsQATester should be included", call.properties.getBoolean("isQATester")); + assertEquals("ExperimentID should be included", "exp_789", call.properties.getString("$experiment_id")); + assertTrue("IsExperimentActive should be included", call.properties.getBoolean("$is_experiment_active")); + assertTrue("IsQATester should be included", call.properties.getBoolean("$is_qa_tester")); } } diff --git a/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java index d993afdc5..d2fc245a3 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/FeatureFlagManager.java @@ -713,13 +713,13 @@ private void _performTrackingDelegateCall(String flagName, MixpanelFlagVariant v } if (variant.experimentID != null) { - properties.put("experimentID", variant.experimentID); + properties.put("$experiment_id", variant.experimentID); } if (variant.isExperimentActive != null) { - properties.put("isExperimentActive", variant.isExperimentActive); + properties.put("$is_experiment_active", variant.isExperimentActive); } if (variant.isQATester != null) { - properties.put("isQATester", variant.isQATester); + properties.put("$is_qa_tester", variant.isQATester); } } catch (JSONException e) { MPLog.e(LOGTAG, "Failed to create JSON properties for $experiment_started event", e); From bc81030539e3a6f74f0438d49114bd7b217be0c3 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Wed, 24 Sep 2025 15:25:57 -0700 Subject: [PATCH 3/3] Fix incorrect Javadoc references to FeatureFlagData Updated all Javadoc comments in MixpanelFlagVariant to correctly reference MixpanelFlagVariant instead of the old FeatureFlagData class name. --- .../android/mpmetrics/MixpanelFlagVariant.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java index 48c598505..d152384ce 100644 --- a/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java +++ b/src/main/java/com/mixpanel/android/mpmetrics/MixpanelFlagVariant.java @@ -44,7 +44,7 @@ public class MixpanelFlagVariant { public final Boolean isQATester; /** - * Constructs a {@code FeatureFlagData} object when parsing an API response. + * Constructs a {@code MixpanelFlagVariant} object when parsing an API response. * * @param key The key of the feature flag variant. Corresponds to 'variant_key' from the API. Cannot be null. * @param value The value of the feature flag variant. Corresponds to 'variant_value' from the API. @@ -59,7 +59,7 @@ public MixpanelFlagVariant(@NonNull String key, @Nullable Object value) { } /** - * Constructs a {@code FeatureFlagData} object when parsing an API response with optional experiment fields. + * Constructs a {@code MixpanelFlagVariant} object when parsing an API response with optional experiment fields. * * @param key The key of the feature flag variant. Corresponds to 'variant_key' from the API. Cannot be null. * @param value The value of the feature flag variant. Corresponds to 'variant_value' from the API. @@ -77,7 +77,7 @@ public MixpanelFlagVariant(@NonNull String key, @Nullable Object value, @Nullabl } /** - * Constructs a {@code FeatureFlagData} object for creating fallback instances. + * Constructs a {@code MixpanelFlagVariant} object for creating fallback instances. * In this case, the provided {@code keyAndValue} is used as both the key and the value * for the feature flag data. This is typically used when a flag is not found * and a default string value needs to be returned. @@ -93,7 +93,7 @@ public MixpanelFlagVariant(@NonNull String keyAndValue) { } /** - * Constructs a {@code FeatureFlagData} object for creating fallback instances. + * Constructs a {@code MixpanelFlagVariant} object for creating fallback instances. * In this version, the key is set to an empty string (""), and the provided {@code value} * is used as the value for the feature flag data. This is typically used when a * flag is not found or an error occurs, and a default value needs to be provided. @@ -110,7 +110,7 @@ public MixpanelFlagVariant(@NonNull Object value) { } /** - * Default constructor that initializes an empty {@code FeatureFlagData} object. + * Default constructor that initializes an empty {@code MixpanelFlagVariant} object. * The key is set to an empty string ("") and the value is set to null. * This constructor might be used internally or for specific default cases. */