From bcae67a0246ed36d692d94fed563ab1b1c4e63a1 Mon Sep 17 00:00:00 2001 From: Ketan S Date: Thu, 12 Nov 2026 13:27:54 +0530 Subject: [PATCH] feat: tag OpenFeature exposures with $openfeature_provider (SDK-448) Exposures fired through the Mixpanel OpenFeature provider now include "$openfeature_provider": true so they can be told apart from direct SDK flag calls. Direct SDK calls are unchanged. No public API change: the provider signals the SDK through an argument it already passes on every evaluation. Co-Authored-By: Claude Opus 5.5 --- .../openfeature/MixpanelProvider.java | 22 +++++----- .../openfeature/MixpanelProviderTest.java | 40 +++++++++++++++++++ .../provider/BaseFlagsProvider.java | 16 ++++++++ .../provider/LocalFlagsProvider.java | 1 + .../provider/RemoteFlagsProvider.java | 1 + .../provider/LocalFlagsProviderTest.java | 36 +++++++++++++++++ .../provider/RemoteFlagsProviderTest.java | 28 +++++++++++++ 7 files changed, 134 insertions(+), 10 deletions(-) diff --git a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java index 251eea9..be8a135 100644 --- a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java +++ b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java @@ -206,17 +206,19 @@ private T coerce(Object value, Class targetType) { static Map convertContext(EvaluationContext ctx) { Map context = new HashMap<>(); - if (ctx == null) { - return context; - } - String targetingKey = ctx.getTargetingKey(); - if (targetingKey != null && !targetingKey.isEmpty()) { - context.put("targetingKey", targetingKey); - } - for (String key : ctx.keySet()) { - Value val = ctx.getValue(key); - context.put(key, unwrapValue(val)); + if (ctx != null) { + String targetingKey = ctx.getTargetingKey(); + if (targetingKey != null && !targetingKey.isEmpty()) { + context.put("targetingKey", targetingKey); + } + for (String key : ctx.keySet()) { + Value val = ctx.getValue(key); + context.put(key, unwrapValue(val)); + } } + // SDK-448: marks exposures fired through this provider. Set after the + // customer attributes so it can't be overridden; older SDKs ignore the key. + context.put("$openfeature_provider", true); return context; } diff --git a/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java b/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java index 0a35ab0..6ecae70 100644 --- a/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java +++ b/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java @@ -402,6 +402,46 @@ public void testExplicitTargetingKeyAttributeOverriddenByGetTargetingKey() { assertEquals("user-123", captured.get("distinct_id")); } + @SuppressWarnings("unchecked") + @Test + public void testContextIncludesOpenFeatureProviderMarker() { + SelectedVariant variant = new SelectedVariant<>("on", true, null, null, null); + ArgumentCaptor> contextCaptor = ArgumentCaptor.forClass(Map.class); + when(mockFlagsProvider.getVariant(eq("flag"), any(SelectedVariant.class), contextCaptor.capture(), eq(true))) + .thenReturn(variant); + + Map attrs = new HashMap<>(); + attrs.put("distinct_id", new Value("user-123")); + provider.getBooleanEvaluation("flag", false, new ImmutableContext(attrs)); + + Map captured = contextCaptor.getValue(); + assertEquals(Boolean.TRUE, captured.get("$openfeature_provider")); + assertEquals("user-123", captured.get("distinct_id")); + } + + @SuppressWarnings("unchecked") + @Test + public void testOpenFeatureProviderMarkerCannotBeOverriddenByCustomerAttribute() { + SelectedVariant variant = new SelectedVariant<>("on", true, null, null, null); + ArgumentCaptor> contextCaptor = ArgumentCaptor.forClass(Map.class); + when(mockFlagsProvider.getVariant(eq("flag"), any(SelectedVariant.class), contextCaptor.capture(), eq(true))) + .thenReturn(variant); + + Map attrs = new HashMap<>(); + attrs.put("distinct_id", new Value("user-123")); + attrs.put("$openfeature_provider", new Value(false)); + provider.getBooleanEvaluation("flag", false, new ImmutableContext(attrs)); + + Map captured = contextCaptor.getValue(); + assertEquals(Boolean.TRUE, captured.get("$openfeature_provider")); + } + + @Test + public void testConvertContextIncludesMarkerForNullContext() { + Map context = MixpanelProvider.convertContext(null); + assertEquals(Boolean.TRUE, context.get("$openfeature_provider")); + } + // PROVIDER_NOT_READY @Test diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java index 89f05c4..b0e48f9 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java @@ -242,6 +242,22 @@ protected void trackExposure(String distinctId, String flagKey, String variantKe } } + /** + * Context key set by the Mixpanel OpenFeature provider (SDK-448). When present and + * {@code true}, exposure events are tagged with {@code "$openfeature_provider": true}. + */ + static final String OPENFEATURE_PROVIDER_KEY = "$openfeature_provider"; + + /** + * Adds {@code "$openfeature_provider": true} to the exposure properties when the + * evaluation context was built by the Mixpanel OpenFeature provider. + */ + static void addOpenFeatureProviderProperty(Map context, JSONObject properties) { + if (context != null && Boolean.TRUE.equals(context.get(OPENFEATURE_PROVIDER_KEY))) { + properties.put(OPENFEATURE_PROVIDER_KEY, true); + } + } + // #endregion /** diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java index f6c1aff..0ca3ade 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -728,6 +728,7 @@ private void trackLocalExposure(Map context, String flagKey, Str trackExposure(distinctIdObj.toString(), flagKey, variantKey, "local", properties -> { properties.put("Variant fetch latency (ms)", latencyMs); + addOpenFeatureProviderProperty(context, properties); }, experimentId, isExperimentActive, isQaTester); } diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java index d2a84b9..a2fc7dd 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java @@ -193,6 +193,7 @@ private void trackRemoteExposure(Map context, String flagKey, St trackExposure(distinctIdObj.toString(), flagKey, variantKey, "remote", properties -> { properties.put("Variant fetch start time", startTime); properties.put("Variant fetch complete time", completeTime); + addOpenFeatureProviderProperty(context, properties); }, experimentId, isExperimentActive, isQaTester); } diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java index 4b747f7..a9e29b6 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -944,6 +944,42 @@ public void testTrackExposureWhenVariantIsSelected() { assertTrue(event.properties.getLong("Variant fetch latency (ms)") >= 0); } + @Test + public void testExposureIncludesOpenFeatureProviderPropertyWhenContextMarkerSet() { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, null); + + provider = createProviderWithResponse(response); + + Map context = buildContext("user-123"); + context.put("$openfeature_provider", true); + provider.startPollingForDefinitions(); + provider.getVariantValue(flagKey, fallbackVariantValue, context); + + assertEquals(1, eventSender.getEvents().size()); + MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1); + assertEquals("variant-a", event.properties.getString("Variant name")); + assertTrue(event.properties.getBoolean("$openfeature_provider")); + } + + @Test + public void testExposureOmitsOpenFeatureProviderPropertyWithoutContextMarker() { + List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); + List rollouts = Arrays.asList(new Rollout(1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, null); + + provider = createProviderWithResponse(response); + + Map context = buildContext("user-123"); + provider.startPollingForDefinitions(); + provider.getVariantValue(flagKey, fallbackVariantValue, context); + + assertEquals(1, eventSender.getEvents().size()); + MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1); + assertFalse(event.properties.has("$openfeature_provider")); + } + @Test public void testDoNotTrackExposureWhenReturningFallback() { // Empty flags - will return fallback diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java index bc41ccf..648674c 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java @@ -244,6 +244,34 @@ public void testTrackExposureWhenVariantIsSelected() { assertNotNull(event.properties.getString("Variant fetch complete time")); } + @Test + public void testExposureIncludesOpenFeatureProviderPropertyWhenContextMarkerSet() { + String response = buildRemoteFlagsResponse("test-flag", "variant-a", "test-value"); + provider = createProviderWithResponse(response); + + Map context = buildContext("user-123"); + context.put("$openfeature_provider", true); + provider.getVariantValue("test-flag", "fallback", context); + + assertEquals(1, eventSender.getEvents().size()); + MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1); + assertEquals("variant-a", event.properties.getString("Variant name")); + assertTrue(event.properties.getBoolean("$openfeature_provider")); + } + + @Test + public void testExposureOmitsOpenFeatureProviderPropertyWithoutContextMarker() { + String response = buildRemoteFlagsResponse("test-flag", "variant-a", "test-value"); + provider = createProviderWithResponse(response); + + Map context = buildContext("user-123"); + provider.getVariantValue("test-flag", "fallback", context); + + assertEquals(1, eventSender.getEvents().size()); + MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1); + assertFalse(event.properties.has("$openfeature_provider")); + } + @Test public void testDoNotTrackExposureWhenReturningFallback() { // Create provider that will throw IOException