From 6c7ddcea0663e43f5e79598946aab890af851db6 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 19 Mar 2026 11:43:51 -0700 Subject: [PATCH 01/17] feat: Add OpenFeature provider for Mixpanel Java SDK Implements an OpenFeature FeatureProvider that wraps BaseFlagsProvider, enabling Mixpanel feature flags to be used via the OpenFeature standard. Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/pom.xml | 83 ++++ .../openfeature/MixpanelProvider.java | 270 ++++++++++ .../openfeature/MixpanelProviderTest.java | 468 ++++++++++++++++++ 3 files changed, 821 insertions(+) create mode 100644 openfeature-provider/pom.xml create mode 100644 openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java create mode 100644 openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java diff --git a/openfeature-provider/pom.xml b/openfeature-provider/pom.xml new file mode 100644 index 0000000..bcd885b --- /dev/null +++ b/openfeature-provider/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + com.mixpanel + mixpanel-java-openfeature-provider + 1.7.0 + jar + Mixpanel Java SDK - OpenFeature Provider + + + + + https://github.com/mixpanel/mixpanel-java + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + A business-friendly OSS license + + + + + scm:git:https://github.com/mixpanel/mixpanel-java.git + scm:git:git@github.com:mixpanel/mixpanel-java.git + https://github.com/mixpanel/mixpanel-java + + + + + mixpanel + Mixpanel, Inc + dev@mixpanel.com + http://www.mixpanel.com + + + + + UTF-8 + 1.8 + 1.8 + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + + + + com.mixpanel + mixpanel-java + ${project.version} + + + + dev.openfeature + sdk + 1.20.1 + + + + junit + junit + 4.13.2 + test + + + + org.mockito + mockito-core + 4.11.0 + test + + + diff --git a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java new file mode 100644 index 0000000..6bbaf86 --- /dev/null +++ b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java @@ -0,0 +1,270 @@ +package com.mixpanel.openfeature; + +import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; +import com.mixpanel.mixpanelapi.featureflags.provider.BaseFlagsProvider; +import com.mixpanel.mixpanelapi.featureflags.provider.LocalFlagsProvider; +import dev.openfeature.sdk.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class MixpanelProvider implements FeatureProvider { + + private final BaseFlagsProvider flagsProvider; + private volatile Map globalContext = new HashMap<>(); + + public MixpanelProvider(BaseFlagsProvider flagsProvider) { + this.flagsProvider = flagsProvider; + } + + @Override + public void initialize(EvaluationContext evaluationContext) throws Exception { + this.globalContext = convertContext(evaluationContext); + } + + @Override + public Metadata getMetadata() { + return () -> "mixpanel-provider"; + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + return evaluate(key, defaultValue, Boolean.class); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + return evaluate(key, defaultValue, String.class); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + return evaluate(key, defaultValue, Integer.class); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + return evaluate(key, defaultValue, Double.class); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + ProviderEvaluation notReadyResult = checkNotReady(defaultValue); + if (notReadyResult != null) { + return notReadyResult; + } + + SelectedVariant fallback = new SelectedVariant<>(null); + + SelectedVariant result; + try { + result = flagsProvider.getVariant(key, fallback, globalContext, true); + } catch (Exception e) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason("ERROR") + .errorCode(ErrorCode.GENERAL) + .errorMessage(e.getMessage()) + .build(); + } + + if (result.isFallback()) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason("ERROR") + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .errorMessage("Flag not found: " + key) + .build(); + } + + Value value = objectToValue(result.getVariantValue()); + return ProviderEvaluation.builder() + .value(value) + .variant(result.getVariantKey()) + .reason("STATIC") + .build(); + } + + @Override + public void shutdown() { + // No-op + } + + private ProviderEvaluation evaluate(String key, T defaultValue, Class expectedType) { + ProviderEvaluation notReadyResult = checkNotReady(defaultValue); + if (notReadyResult != null) { + return notReadyResult; + } + + SelectedVariant fallback = new SelectedVariant<>(null); + + SelectedVariant result; + try { + result = flagsProvider.getVariant(key, fallback, globalContext, true); + } catch (Exception e) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason("ERROR") + .errorCode(ErrorCode.GENERAL) + .errorMessage(e.getMessage()) + .build(); + } + + if (result.isFallback()) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason("ERROR") + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .errorMessage("Flag not found: " + key) + .build(); + } + + T typedValue = coerce(result.getVariantValue(), expectedType); + if (typedValue == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason("ERROR") + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage("Expected " + expectedType.getSimpleName() + " but got " + result.getVariantValue().getClass().getSimpleName()) + .build(); + } + + return ProviderEvaluation.builder() + .value(typedValue) + .variant(result.getVariantKey()) + .reason("STATIC") + .build(); + } + + private ProviderEvaluation checkNotReady(T defaultValue) { + if (flagsProvider instanceof LocalFlagsProvider) { + LocalFlagsProvider localProvider = (LocalFlagsProvider) flagsProvider; + if (!localProvider.areFlagsReady()) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason("ERROR") + .errorCode(ErrorCode.PROVIDER_NOT_READY) + .errorMessage("Provider not ready") + .build(); + } + } + return null; + } + + @SuppressWarnings("unchecked") + private T coerce(Object value, Class targetType) { + if (value == null) { + return null; + } + if (targetType.isInstance(value)) { + return targetType.cast(value); + } + if (targetType == Integer.class && value instanceof Number) { + long longVal = ((Number) value).longValue(); + if (longVal < Integer.MIN_VALUE || longVal > Integer.MAX_VALUE) { + return null; + } + return (T) Integer.valueOf((int) longVal); + } + if (targetType == Double.class && value instanceof Number) { + return (T) Double.valueOf(((Number) value).doubleValue()); + } + return null; + } + + static Map convertContext(EvaluationContext ctx) { + Map context = new HashMap<>(); + if (ctx == null) { + return context; + } + for (String key : ctx.keySet()) { + Value val = ctx.getValue(key); + context.put(key, unwrapValue(val)); + } + return context; + } + + private static Object unwrapValue(Value value) { + if (value == null || value.isNull()) { + return null; + } + if (value.isBoolean()) { + return value.asBoolean(); + } + if (value.isNumber()) { + double d = value.asDouble(); + if (d == Math.floor(d) && !Double.isInfinite(d) + && d >= Long.MIN_VALUE && d <= Long.MAX_VALUE) { + return (long) d; + } + return d; + } + if (value.isString()) { + return value.asString(); + } + if (value.isList()) { + List list = value.asList(); + Object[] arr = new Object[list.size()]; + for (int i = 0; i < list.size(); i++) { + arr[i] = unwrapValue(list.get(i)); + } + return java.util.Arrays.asList(arr); + } + if (value.isStructure()) { + Map struct = value.asStructure().asMap(); + Map map = new HashMap<>(); + for (Map.Entry entry : struct.entrySet()) { + map.put(entry.getKey(), unwrapValue(entry.getValue())); + } + return map; + } + return value.asObject(); + } + + private static Value objectToValue(Object obj) { + if (obj == null) { + return new Value(); + } + if (obj instanceof Boolean) { + return new Value((Boolean) obj); + } + if (obj instanceof Integer) { + return new Value((Integer) obj); + } + if (obj instanceof Long) { + long l = (Long) obj; + if (l >= Integer.MIN_VALUE && l <= Integer.MAX_VALUE) { + return new Value((int) l); + } + return new Value((double) l); + } + if (obj instanceof Double) { + return new Value((Double) obj); + } + if (obj instanceof Float) { + return new Value(((Float) obj).doubleValue()); + } + if (obj instanceof String) { + return new Value((String) obj); + } + if (obj instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) obj; + Map structure = new HashMap<>(); + for (Map.Entry entry : map.entrySet()) { + structure.put(entry.getKey(), objectToValue(entry.getValue())); + } + return new Value(new ImmutableStructure(structure)); + } + if (obj instanceof List) { + List list = (List) obj; + java.util.ArrayList values = new java.util.ArrayList<>(); + for (Object item : list) { + values.add(objectToValue(item)); + } + return new Value(values); + } + return new Value(obj.toString()); + } +} diff --git a/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java b/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java new file mode 100644 index 0000000..c929690 --- /dev/null +++ b/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java @@ -0,0 +1,468 @@ +package com.mixpanel.openfeature; + +import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; +import com.mixpanel.mixpanelapi.featureflags.provider.BaseFlagsProvider; +import com.mixpanel.mixpanelapi.featureflags.provider.LocalFlagsProvider; +import dev.openfeature.sdk.*; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class MixpanelProviderTest { + + private BaseFlagsProvider mockFlagsProvider; + private MixpanelProvider provider; + + @SuppressWarnings("unchecked") + @Before + public void setUp() { + mockFlagsProvider = mock(BaseFlagsProvider.class); + provider = new MixpanelProvider(mockFlagsProvider); + } + + // Metadata + + @Test + public void testGetMetadataReturnsCorrectName() { + Metadata metadata = provider.getMetadata(); + assertEquals("mixpanel-provider", metadata.getName()); + } + + // Boolean evaluation + + @SuppressWarnings("unchecked") + @Test + public void testBooleanEvaluationSuccess() { + SelectedVariant variant = new SelectedVariant<>("on", true, null, null, null); + when(mockFlagsProvider.getVariant(eq("bool-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext()); + + assertTrue(result.getValue()); + assertEquals("on", result.getVariant()); + assertEquals("STATIC", result.getReason()); + assertNull(result.getErrorCode()); + } + + @SuppressWarnings("unchecked") + @Test + public void testBooleanEvaluationTypeMismatch() { + SelectedVariant variant = new SelectedVariant<>("on", "not-a-boolean", null, null, null); + when(mockFlagsProvider.getVariant(eq("bool-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getBooleanEvaluation("bool-flag", false, new ImmutableContext()); + + assertFalse(result.getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + @SuppressWarnings("unchecked") + @Test + public void testBooleanEvaluationFlagNotFound() { + SelectedVariant fallback = new SelectedVariant<>(false); + when(mockFlagsProvider.getVariant(eq("missing-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(fallback); + + ProviderEvaluation result = provider.getBooleanEvaluation("missing-flag", false, new ImmutableContext()); + + assertFalse(result.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + // String evaluation + + @SuppressWarnings("unchecked") + @Test + public void testStringEvaluationSuccess() { + SelectedVariant variant = new SelectedVariant<>("blue", "blue", null, null, null); + when(mockFlagsProvider.getVariant(eq("color-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getStringEvaluation("color-flag", "red", new ImmutableContext()); + + assertEquals("blue", result.getValue()); + assertEquals("blue", result.getVariant()); + assertEquals("STATIC", result.getReason()); + assertNull(result.getErrorCode()); + } + + @SuppressWarnings("unchecked") + @Test + public void testStringEvaluationTypeMismatch() { + SelectedVariant variant = new SelectedVariant<>("on", 42, null, null, null); + when(mockFlagsProvider.getVariant(eq("string-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getStringEvaluation("string-flag", "default", new ImmutableContext()); + + assertEquals("default", result.getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + @SuppressWarnings("unchecked") + @Test + public void testStringEvaluationFlagNotFound() { + SelectedVariant fallback = new SelectedVariant<>(null); + when(mockFlagsProvider.getVariant(eq("missing-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(fallback); + + ProviderEvaluation result = provider.getStringEvaluation("missing-flag", "fallback", new ImmutableContext()); + + assertEquals("fallback", result.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + // Integer evaluation + + @SuppressWarnings("unchecked") + @Test + public void testIntegerEvaluationSuccess() { + SelectedVariant variant = new SelectedVariant<>("v1", 42, null, null, null); + when(mockFlagsProvider.getVariant(eq("int-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext()); + + assertEquals(Integer.valueOf(42), result.getValue()); + assertEquals("v1", result.getVariant()); + assertEquals("STATIC", result.getReason()); + assertNull(result.getErrorCode()); + } + + @SuppressWarnings("unchecked") + @Test + public void testIntegerEvaluationFromLong() { + SelectedVariant variant = new SelectedVariant<>("v1", 42L, null, null, null); + when(mockFlagsProvider.getVariant(eq("int-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext()); + + assertEquals(Integer.valueOf(42), result.getValue()); + assertEquals("STATIC", result.getReason()); + } + + @SuppressWarnings("unchecked") + @Test + public void testIntegerEvaluationFromDouble() { + SelectedVariant variant = new SelectedVariant<>("v1", 42.0, null, null, null); + when(mockFlagsProvider.getVariant(eq("int-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext()); + + assertEquals(Integer.valueOf(42), result.getValue()); + assertEquals("STATIC", result.getReason()); + } + + @SuppressWarnings("unchecked") + @Test + public void testIntegerEvaluationTypeMismatch() { + SelectedVariant variant = new SelectedVariant<>("v1", "not-a-number", null, null, null); + when(mockFlagsProvider.getVariant(eq("int-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext()); + + assertEquals(Integer.valueOf(0), result.getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + @SuppressWarnings("unchecked") + @Test + public void testIntegerEvaluationFlagNotFound() { + SelectedVariant fallback = new SelectedVariant<>(null); + when(mockFlagsProvider.getVariant(eq("missing-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(fallback); + + ProviderEvaluation result = provider.getIntegerEvaluation("missing-flag", 99, new ImmutableContext()); + + assertEquals(Integer.valueOf(99), result.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + @SuppressWarnings("unchecked") + @Test + public void testIntegerEvaluationOverflowReturnsMismatch() { + SelectedVariant variant = new SelectedVariant<>("v1", Long.MAX_VALUE, null, null, null); + when(mockFlagsProvider.getVariant(eq("int-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext()); + + assertEquals(Integer.valueOf(0), result.getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, result.getErrorCode()); + } + + // Double evaluation + + @SuppressWarnings("unchecked") + @Test + public void testDoubleEvaluationSuccess() { + SelectedVariant variant = new SelectedVariant<>("v1", 3.14, null, null, null); + when(mockFlagsProvider.getVariant(eq("double-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getDoubleEvaluation("double-flag", 0.0, new ImmutableContext()); + + assertEquals(Double.valueOf(3.14), result.getValue()); + assertEquals("v1", result.getVariant()); + assertEquals("STATIC", result.getReason()); + assertNull(result.getErrorCode()); + } + + @SuppressWarnings("unchecked") + @Test + public void testDoubleEvaluationFromInteger() { + SelectedVariant variant = new SelectedVariant<>("v1", 42, null, null, null); + when(mockFlagsProvider.getVariant(eq("double-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getDoubleEvaluation("double-flag", 0.0, new ImmutableContext()); + + assertEquals(Double.valueOf(42.0), result.getValue()); + assertEquals("STATIC", result.getReason()); + } + + @SuppressWarnings("unchecked") + @Test + public void testDoubleEvaluationTypeMismatch() { + SelectedVariant variant = new SelectedVariant<>("v1", "not-a-number", null, null, null); + when(mockFlagsProvider.getVariant(eq("double-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getDoubleEvaluation("double-flag", 0.0, new ImmutableContext()); + + assertEquals(Double.valueOf(0.0), result.getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + @SuppressWarnings("unchecked") + @Test + public void testDoubleEvaluationFlagNotFound() { + SelectedVariant fallback = new SelectedVariant<>(null); + when(mockFlagsProvider.getVariant(eq("missing-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(fallback); + + ProviderEvaluation result = provider.getDoubleEvaluation("missing-flag", 9.9, new ImmutableContext()); + + assertEquals(Double.valueOf(9.9), result.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + // Object evaluation + + @SuppressWarnings("unchecked") + @Test + public void testObjectEvaluationSuccess() { + Map objValue = new HashMap<>(); + objValue.put("key", "value"); + SelectedVariant variant = new SelectedVariant<>("v1", objValue, null, null, null); + when(mockFlagsProvider.getVariant(eq("obj-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getObjectEvaluation("obj-flag", new Value(), new ImmutableContext()); + + assertNotNull(result.getValue()); + assertEquals("v1", result.getVariant()); + assertEquals("STATIC", result.getReason()); + assertNull(result.getErrorCode()); + } + + @SuppressWarnings("unchecked") + @Test + public void testObjectEvaluationFlagNotFound() { + SelectedVariant fallback = new SelectedVariant<>(null); + when(mockFlagsProvider.getVariant(eq("missing-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(fallback); + + Value defaultValue = new Value("default"); + ProviderEvaluation result = provider.getObjectEvaluation("missing-flag", defaultValue, new ImmutableContext()); + + assertEquals(defaultValue, result.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + // Context handling — global context set via initialize(), not per-evaluation + + @SuppressWarnings("unchecked") + @Test + public void testInitializeSetsGlobalContext() throws Exception { + 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("plan", new Value("pro")); + attrs.put("age", new Value(25)); + provider.initialize(new ImmutableContext(attrs)); + + provider.getBooleanEvaluation("flag", false, new ImmutableContext()); + + Map captured = contextCaptor.getValue(); + assertEquals("pro", captured.get("plan")); + assertEquals(25L, captured.get("age")); + } + + @SuppressWarnings("unchecked") + @Test + public void testPerEvaluationContextIsIgnored() throws Exception { + 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 globalAttrs = new HashMap<>(); + globalAttrs.put("source", new Value("global")); + provider.initialize(new ImmutableContext(globalAttrs)); + + // Pass a different per-eval context — it should be ignored + Map perEvalAttrs = new HashMap<>(); + perEvalAttrs.put("source", new Value("per-eval")); + provider.getBooleanEvaluation("flag", false, new ImmutableContext(perEvalAttrs)); + + Map captured = contextCaptor.getValue(); + assertEquals("global", captured.get("source")); + } + + @SuppressWarnings("unchecked") + @Test + public void testTargetingKeyIsRegularProperty() throws Exception { + 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("targetingKey", new Value("tk-value")); + attrs.put("distinct_id", new Value("user-123")); + provider.initialize(new ImmutableContext(attrs)); + + provider.getBooleanEvaluation("flag", false, new ImmutableContext()); + + Map captured = contextCaptor.getValue(); + // targetingKey should be passed as-is, not treated specially + assertEquals("tk-value", captured.get("targetingKey")); + assertEquals("user-123", captured.get("distinct_id")); + } + + // PROVIDER_NOT_READY + + @Test + public void testProviderNotReadyWithLocalProvider() { + LocalFlagsProvider mockLocal = mock(LocalFlagsProvider.class); + when(mockLocal.areFlagsReady()).thenReturn(false); + MixpanelProvider localProvider = new MixpanelProvider(mockLocal); + + ProviderEvaluation result = localProvider.getBooleanEvaluation("flag", true, new ImmutableContext()); + + assertTrue(result.getValue()); + assertEquals(ErrorCode.PROVIDER_NOT_READY, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + @Test + public void testProviderNotReadyObjectEvaluation() { + LocalFlagsProvider mockLocal = mock(LocalFlagsProvider.class); + when(mockLocal.areFlagsReady()).thenReturn(false); + MixpanelProvider localProvider = new MixpanelProvider(mockLocal); + + Value defaultValue = new Value("default"); + ProviderEvaluation result = localProvider.getObjectEvaluation("flag", defaultValue, new ImmutableContext()); + + assertEquals(defaultValue, result.getValue()); + assertEquals(ErrorCode.PROVIDER_NOT_READY, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + @SuppressWarnings("unchecked") + @Test + public void testProviderReadyWithLocalProvider() { + LocalFlagsProvider mockLocal = mock(LocalFlagsProvider.class); + when(mockLocal.areFlagsReady()).thenReturn(true); + SelectedVariant variant = new SelectedVariant<>("on", true, null, null, null); + when(mockLocal.getVariant(eq("flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + MixpanelProvider localProvider = new MixpanelProvider(mockLocal); + + ProviderEvaluation result = localProvider.getBooleanEvaluation("flag", false, new ImmutableContext()); + + assertTrue(result.getValue()); + assertEquals("STATIC", result.getReason()); + assertNull(result.getErrorCode()); + } + + @SuppressWarnings("unchecked") + @Test + public void testProviderNotReadySkippedForNonLocalProvider() { + // BaseFlagsProvider (non-local) should not check readiness + SelectedVariant variant = new SelectedVariant<>("on", true, null, null, null); + when(mockFlagsProvider.getVariant(eq("flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getBooleanEvaluation("flag", false, new ImmutableContext()); + + assertTrue(result.getValue()); + assertEquals("STATIC", result.getReason()); + assertNull(result.getErrorCode()); + } + + // Exception handling + + @SuppressWarnings("unchecked") + @Test + public void testExceptionReturnDefaultValue() { + when(mockFlagsProvider.getVariant(eq("error-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenThrow(new RuntimeException("something went wrong")); + + ProviderEvaluation result = provider.getBooleanEvaluation("error-flag", true, new ImmutableContext()); + + assertTrue(result.getValue()); + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + // Object evaluation type mismatch (object eval returns value as-is, so this tests exception path) + + @SuppressWarnings("unchecked") + @Test + public void testObjectEvaluationException() { + when(mockFlagsProvider.getVariant(eq("obj-flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenThrow(new RuntimeException("conversion error")); + + Value defaultValue = new Value("default"); + ProviderEvaluation result = provider.getObjectEvaluation("obj-flag", defaultValue, new ImmutableContext()); + + assertEquals(defaultValue, result.getValue()); + assertEquals(ErrorCode.GENERAL, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + // Shutdown + + @Test + public void testShutdownIsNoOp() { + // Should not throw + provider.shutdown(); + } +} From 97a4c2e44904321ec62069cd6b132fe78a512cb2 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Fri, 20 Mar 2026 11:05:07 -0700 Subject: [PATCH 02/17] Fix Java provider to use merged evaluation context instead of global The OpenFeature SDK merges all context layers before calling the provider. The provider was ignoring this merged context and using a stored global context from initialize(). Now uses convertContext(ctx) in evaluate() and getObjectEvaluation(). Also adds variant key passthrough and null variant key tests. Co-Authored-By: Claude Opus 4.6 --- .../openfeature/MixpanelProvider.java | 30 +++--- .../openfeature/MixpanelProviderTest.java | 94 ++++++++++++++----- 2 files changed, 85 insertions(+), 39 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 6bbaf86..ca00525 100644 --- a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java +++ b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java @@ -12,17 +12,11 @@ public class MixpanelProvider implements FeatureProvider { private final BaseFlagsProvider flagsProvider; - private volatile Map globalContext = new HashMap<>(); public MixpanelProvider(BaseFlagsProvider flagsProvider) { this.flagsProvider = flagsProvider; } - @Override - public void initialize(EvaluationContext evaluationContext) throws Exception { - this.globalContext = convertContext(evaluationContext); - } - @Override public Metadata getMetadata() { return () -> "mixpanel-provider"; @@ -30,22 +24,22 @@ public Metadata getMetadata() { @Override public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { - return evaluate(key, defaultValue, Boolean.class); + return evaluate(key, defaultValue, Boolean.class, ctx); } @Override public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { - return evaluate(key, defaultValue, String.class); + return evaluate(key, defaultValue, String.class, ctx); } @Override public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { - return evaluate(key, defaultValue, Integer.class); + return evaluate(key, defaultValue, Integer.class, ctx); } @Override public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { - return evaluate(key, defaultValue, Double.class); + return evaluate(key, defaultValue, Double.class, ctx); } @Override @@ -59,7 +53,7 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa SelectedVariant result; try { - result = flagsProvider.getVariant(key, fallback, globalContext, true); + result = flagsProvider.getVariant(key, fallback, convertContext(ctx), true); } catch (Exception e) { return ProviderEvaluation.builder() .value(defaultValue) @@ -91,7 +85,7 @@ public void shutdown() { // No-op } - private ProviderEvaluation evaluate(String key, T defaultValue, Class expectedType) { + private ProviderEvaluation evaluate(String key, T defaultValue, Class expectedType, EvaluationContext ctx) { ProviderEvaluation notReadyResult = checkNotReady(defaultValue); if (notReadyResult != null) { return notReadyResult; @@ -101,7 +95,7 @@ private ProviderEvaluation evaluate(String key, T defaultValue, Class SelectedVariant result; try { - result = flagsProvider.getVariant(key, fallback, globalContext, true); + result = flagsProvider.getVariant(key, fallback, convertContext(ctx), true); } catch (Exception e) { return ProviderEvaluation.builder() .value(defaultValue) @@ -194,9 +188,13 @@ private static Object unwrapValue(Value value) { } if (value.isNumber()) { double d = value.asDouble(); - if (d == Math.floor(d) && !Double.isInfinite(d) - && d >= Long.MIN_VALUE && d <= Long.MAX_VALUE) { - return (long) d; + if (d == Math.floor(d) && !Double.isInfinite(d)) { + if (d >= Integer.MIN_VALUE && d <= Integer.MAX_VALUE) { + return (int) d; + } + if (d >= Long.MIN_VALUE && d <= Long.MAX_VALUE) { + return (long) d; + } } return d; } 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 c929690..92c017a 100644 --- a/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java +++ b/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java @@ -302,52 +302,47 @@ public void testObjectEvaluationFlagNotFound() { assertEquals("ERROR", result.getReason()); } - // Context handling — global context set via initialize(), not per-evaluation + // Context handling — merged context from ctx parameter is forwarded @SuppressWarnings("unchecked") @Test - public void testInitializeSetsGlobalContext() throws Exception { + public void testPerEvaluationContextIsForwarded() { 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("plan", new Value("pro")); - attrs.put("age", new Value(25)); - provider.initialize(new ImmutableContext(attrs)); - - provider.getBooleanEvaluation("flag", false, new ImmutableContext()); + Map perEvalAttrs = new HashMap<>(); + perEvalAttrs.put("plan", new Value("pro")); + perEvalAttrs.put("age", new Value(25)); + provider.getBooleanEvaluation("flag", false, new ImmutableContext(perEvalAttrs)); Map captured = contextCaptor.getValue(); assertEquals("pro", captured.get("plan")); - assertEquals(25L, captured.get("age")); + assertEquals(25, captured.get("age")); } @SuppressWarnings("unchecked") @Test - public void testPerEvaluationContextIsIgnored() throws Exception { - SelectedVariant variant = new SelectedVariant<>("on", true, null, null, null); + public void testPerEvaluationContextIsForwardedForObjectEvaluation() { + Map objValue = new HashMap<>(); + objValue.put("key", "value"); + SelectedVariant variant = new SelectedVariant<>("v1", objValue, null, null, null); ArgumentCaptor> contextCaptor = ArgumentCaptor.forClass(Map.class); - when(mockFlagsProvider.getVariant(eq("flag"), any(SelectedVariant.class), contextCaptor.capture(), eq(true))) + when(mockFlagsProvider.getVariant(eq("obj-flag"), any(SelectedVariant.class), contextCaptor.capture(), eq(true))) .thenReturn(variant); - Map globalAttrs = new HashMap<>(); - globalAttrs.put("source", new Value("global")); - provider.initialize(new ImmutableContext(globalAttrs)); - - // Pass a different per-eval context — it should be ignored Map perEvalAttrs = new HashMap<>(); perEvalAttrs.put("source", new Value("per-eval")); - provider.getBooleanEvaluation("flag", false, new ImmutableContext(perEvalAttrs)); + provider.getObjectEvaluation("obj-flag", new Value(), new ImmutableContext(perEvalAttrs)); Map captured = contextCaptor.getValue(); - assertEquals("global", captured.get("source")); + assertEquals("per-eval", captured.get("source")); } @SuppressWarnings("unchecked") @Test - public void testTargetingKeyIsRegularProperty() throws Exception { + public void testTargetingKeyIsRegularProperty() { 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))) @@ -356,9 +351,7 @@ public void testTargetingKeyIsRegularProperty() throws Exception { Map attrs = new HashMap<>(); attrs.put("targetingKey", new Value("tk-value")); attrs.put("distinct_id", new Value("user-123")); - provider.initialize(new ImmutableContext(attrs)); - - provider.getBooleanEvaluation("flag", false, new ImmutableContext()); + provider.getBooleanEvaluation("flag", false, new ImmutableContext(attrs)); Map captured = contextCaptor.getValue(); // targetingKey should be passed as-is, not treated specially @@ -458,6 +451,61 @@ public void testObjectEvaluationException() { assertEquals("ERROR", result.getReason()); } + // Variant key passthrough + + @SuppressWarnings("unchecked") + @Test + public void testVariantKeyPassedThroughOnBooleanEvaluation() { + SelectedVariant variant = new SelectedVariant<>("my-variant", true, null, null, null); + when(mockFlagsProvider.getVariant(eq("flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getBooleanEvaluation("flag", false, new ImmutableContext()); + + assertEquals("my-variant", result.getVariant()); + } + + @SuppressWarnings("unchecked") + @Test + public void testVariantKeyPassedThroughOnObjectEvaluation() { + SelectedVariant variant = new SelectedVariant<>("obj-variant", "some-value", null, null, null); + when(mockFlagsProvider.getVariant(eq("flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getObjectEvaluation("flag", new Value(), new ImmutableContext()); + + assertEquals("obj-variant", result.getVariant()); + } + + @SuppressWarnings("unchecked") + @Test + public void testNullVariantKeyTreatedAsFallbackOnBooleanEvaluation() { + SelectedVariant variant = new SelectedVariant<>(null, true, null, null, null); + when(mockFlagsProvider.getVariant(eq("flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + ProviderEvaluation result = provider.getBooleanEvaluation("flag", false, new ImmutableContext()); + + assertFalse(result.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + + @SuppressWarnings("unchecked") + @Test + public void testNullVariantKeyTreatedAsFallbackOnObjectEvaluation() { + SelectedVariant variant = new SelectedVariant<>(null, "some-value", null, null, null); + when(mockFlagsProvider.getVariant(eq("flag"), any(SelectedVariant.class), anyMap(), eq(true))) + .thenReturn(variant); + + Value defaultValue = new Value("default"); + ProviderEvaluation result = provider.getObjectEvaluation("flag", defaultValue, new ImmutableContext()); + + assertEquals(defaultValue, result.getValue()); + assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); + assertEquals("ERROR", result.getReason()); + } + // Shutdown @Test From 0cd74d8b24ae83e44adf303088241e413666df48 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Mon, 23 Mar 2026 16:00:31 -0700 Subject: [PATCH 03/17] Align OpenFeature provider with server provider spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shutdown() now delegates to underlying flags provider via shutdown() - Add shutdown() to BaseFlagsProvider (no-op) and LocalFlagsProvider (calls close()) - Reject non-whole decimals in integer coercion (42.5 → TYPE_MISMATCH) Co-Authored-By: Claude Opus 4.6 --- .../com/mixpanel/openfeature/MixpanelProvider.java | 12 +++++++++++- .../featureflags/provider/BaseFlagsProvider.java | 8 ++++++++ .../featureflags/provider/LocalFlagsProvider.java | 5 +++++ 3 files changed, 24 insertions(+), 1 deletion(-) 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 ca00525..a351538 100644 --- a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java +++ b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java @@ -82,7 +82,11 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa @Override public void shutdown() { - // No-op + try { + flagsProvider.shutdown(); + } catch (Exception e) { + // ignore + } } private ProviderEvaluation evaluate(String key, T defaultValue, Class expectedType, EvaluationContext ctx) { @@ -155,6 +159,12 @@ private T coerce(Object value, Class targetType) { return targetType.cast(value); } if (targetType == Integer.class && value instanceof Number) { + if (value instanceof Double || value instanceof Float) { + double doubleVal = ((Number) value).doubleValue(); + if (doubleVal != Math.floor(doubleVal)) { + return null; + } + } long longVal = ((Number) value).longValue(); if (longVal < Integer.MIN_VALUE || longVal > Integer.MAX_VALUE) { return null; 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 b1b7825..9e3f4ed 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java @@ -228,4 +228,12 @@ protected void trackExposure(String distinctId, String flagKey, String variantKe } // #endregion + + /** + * Shuts down this provider and releases any resources. + * Subclasses should override this to perform cleanup. + */ + public void shutdown() { + // No-op by default + } } 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 2b03b64..a69a840 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -680,4 +680,9 @@ public void close() { stopPollingForDefinitions(); } } + + @Override + public void shutdown() { + close(); + } } From b15bae75425ce29aa9a4aea08cb0fae227658444 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Wed, 1 Apr 2026 12:49:05 -0700 Subject: [PATCH 04/17] Simplify OpenFeature provider and LocalFlagsProvider Extract fetchVariant, errorResult, and successResult helpers to eliminate 6 duplicated builder patterns. Add proper imports for Arrays and ArrayList. Extract buildResult in LocalFlagsProvider to consolidate variant construction and exposure tracking. Simplify isQaTester tracking and collapse nested runtime condition checks. Co-Authored-By: Claude Opus 4.6 --- .../openfeature/MixpanelProvider.java | 85 ++++++++----------- .../provider/LocalFlagsProvider.java | 68 ++++++--------- 2 files changed, 62 insertions(+), 91 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 a351538..31de57a 100644 --- a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java +++ b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java @@ -5,6 +5,8 @@ import com.mixpanel.mixpanelapi.featureflags.provider.LocalFlagsProvider; import dev.openfeature.sdk.*; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -49,35 +51,19 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa return notReadyResult; } - SelectedVariant fallback = new SelectedVariant<>(null); - SelectedVariant result; try { - result = flagsProvider.getVariant(key, fallback, convertContext(ctx), true); + result = fetchVariant(key, ctx); } catch (Exception e) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason("ERROR") - .errorCode(ErrorCode.GENERAL) - .errorMessage(e.getMessage()) - .build(); + return errorResult(defaultValue, ErrorCode.GENERAL, e.getMessage()); } if (result.isFallback()) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason("ERROR") - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .errorMessage("Flag not found: " + key) - .build(); + return errorResult(defaultValue, ErrorCode.FLAG_NOT_FOUND, "Flag not found: " + key); } Value value = objectToValue(result.getVariantValue()); - return ProviderEvaluation.builder() - .value(value) - .variant(result.getVariantKey()) - .reason("STATIC") - .build(); + return successResult(value, result.getVariantKey()); } @Override @@ -95,42 +81,44 @@ private ProviderEvaluation evaluate(String key, T defaultValue, Class return notReadyResult; } - SelectedVariant fallback = new SelectedVariant<>(null); - SelectedVariant result; try { - result = flagsProvider.getVariant(key, fallback, convertContext(ctx), true); + result = fetchVariant(key, ctx); } catch (Exception e) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason("ERROR") - .errorCode(ErrorCode.GENERAL) - .errorMessage(e.getMessage()) - .build(); + return errorResult(defaultValue, ErrorCode.GENERAL, e.getMessage()); } if (result.isFallback()) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason("ERROR") - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .errorMessage("Flag not found: " + key) - .build(); + return errorResult(defaultValue, ErrorCode.FLAG_NOT_FOUND, "Flag not found: " + key); } T typedValue = coerce(result.getVariantValue(), expectedType); if (typedValue == null) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason("ERROR") - .errorCode(ErrorCode.TYPE_MISMATCH) - .errorMessage("Expected " + expectedType.getSimpleName() + " but got " + result.getVariantValue().getClass().getSimpleName()) - .build(); + return errorResult(defaultValue, ErrorCode.TYPE_MISMATCH, + "Expected " + expectedType.getSimpleName() + " but got " + result.getVariantValue().getClass().getSimpleName()); } + return successResult(typedValue, result.getVariantKey()); + } + + private SelectedVariant fetchVariant(String key, EvaluationContext ctx) { + SelectedVariant fallback = new SelectedVariant<>(null); + return flagsProvider.getVariant(key, fallback, convertContext(ctx), true); + } + + private ProviderEvaluation errorResult(T defaultValue, ErrorCode errorCode, String errorMessage) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason("ERROR") + .errorCode(errorCode) + .errorMessage(errorMessage) + .build(); + } + + private ProviderEvaluation successResult(T value, String variantKey) { return ProviderEvaluation.builder() - .value(typedValue) - .variant(result.getVariantKey()) + .value(value) + .variant(variantKey) .reason("STATIC") .build(); } @@ -139,12 +127,7 @@ private ProviderEvaluation checkNotReady(T defaultValue) { if (flagsProvider instanceof LocalFlagsProvider) { LocalFlagsProvider localProvider = (LocalFlagsProvider) flagsProvider; if (!localProvider.areFlagsReady()) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason("ERROR") - .errorCode(ErrorCode.PROVIDER_NOT_READY) - .errorMessage("Provider not ready") - .build(); + return errorResult(defaultValue, ErrorCode.PROVIDER_NOT_READY, "Provider not ready"); } } return null; @@ -217,7 +200,7 @@ private static Object unwrapValue(Value value) { for (int i = 0; i < list.size(); i++) { arr[i] = unwrapValue(list.get(i)); } - return java.util.Arrays.asList(arr); + return Arrays.asList(arr); } if (value.isStructure()) { Map struct = value.asStructure().asMap(); @@ -267,7 +250,7 @@ private static Value objectToValue(Object obj) { } if (obj instanceof List) { List list = (List) obj; - java.util.ArrayList values = new java.util.ArrayList<>(); + ArrayList values = new ArrayList<>(); for (Object item : list) { values.add(objectToValue(item)); } 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 a69a840..6176b85 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -352,7 +352,6 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall // Check test user overrides RuleSet ruleset = flag.getRuleset(); - Boolean isQaTester = null; if (ruleset.hasTestUserOverrides()) { String distinctId = context.get("distinct_id") != null ? context.get("distinct_id").toString() : null; if (distinctId != null) { @@ -360,19 +359,7 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall if (testVariantKey != null) { Variant variant = findVariantByKey(ruleset.getVariants(), testVariantKey); if (variant != null) { - isQaTester = true; - @SuppressWarnings("unchecked") - SelectedVariant result = new SelectedVariant<>( - variant.getKey(), - (T) variant.getValue(), - flag.getExperimentId(), - flag.getIsExperimentActive(), - isQaTester - ); - if (reportExposure) { - trackLocalExposure(context, flagKey, variant.getKey(), System.currentTimeMillis() - startTime, flag.getExperimentId(), flag.getIsExperimentActive(), isQaTester); - } - return result; + return buildResult(variant, flag, true, flagKey, context, startTime, reportExposure); } } } @@ -391,45 +378,25 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall } // Check runtime evaluation, continue if this rollout has runtime conditions and it doesn't match - if (rollout.hasLegacyRuntimeEvaluation()) { - if (!matchesLegacyRuntimeConditions(rollout, context)) { - continue; - } + if (rollout.hasLegacyRuntimeEvaluation() && !matchesLegacyRuntimeConditions(rollout, context)) { + continue; } - if (rollout.hasRuntimeEvaluation()) { - if (!matchesRuntimeConditions(rollout, context)) { - continue; - } + if (rollout.hasRuntimeEvaluation() && !matchesRuntimeConditions(rollout, context)) { + continue; } // This rollout is selected - determine variant - Variant selectedVariant = null; - + Variant selectedVariant; if (rollout.hasVariantOverride()) { selectedVariant = findVariantByKey(ruleset.getVariants(), rollout.getVariantOverride().getKey()); } else { - // Calculate variant hash float variantHash = calculateVariantHash(contextValue, flagKey, flag.getHashSalt()); selectedVariant = selectVariantBySplit(ruleset.getVariants(), variantHash, rollout); } if (selectedVariant != null) { - if (isQaTester == null) { - isQaTester = false; - } - @SuppressWarnings("unchecked") - SelectedVariant result = new SelectedVariant<>( - selectedVariant.getKey(), - (T) selectedVariant.getValue(), - flag.getExperimentId(), - flag.getIsExperimentActive(), - isQaTester - ); - if (reportExposure) { - trackLocalExposure(context, flagKey, selectedVariant.getKey(), System.currentTimeMillis() - startTime, flag.getExperimentId(), flag.getIsExperimentActive(), isQaTester); - } - return result; + return buildResult(selectedVariant, flag, false, flagKey, context, startTime, reportExposure); } break; // Rollout selected but no variant found @@ -444,6 +411,27 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall } } + /** + * Builds a SelectedVariant result and optionally tracks exposure. + */ + @SuppressWarnings("unchecked") + private SelectedVariant buildResult(Variant variant, ExperimentationFlag flag, boolean isQaTester, + String flagKey, Map context, + long startTime, boolean reportExposure) { + SelectedVariant result = new SelectedVariant<>( + variant.getKey(), + (T) variant.getValue(), + flag.getExperimentId(), + flag.getIsExperimentActive(), + isQaTester + ); + if (reportExposure) { + trackLocalExposure(context, flagKey, variant.getKey(), System.currentTimeMillis() - startTime, + flag.getExperimentId(), flag.getIsExperimentActive(), isQaTester); + } + return result; + } + private boolean matchesRuntimeConditions(Rollout rollout, Map context) { Map customProperties = getCustomProperties(context); return JsonLogicEngine.evaluate(rollout.getRuntimeEvaluationRule(), customProperties); From 7b07f935b65d8e00711b0106882da4090fc2936f Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Wed, 1 Apr 2026 14:12:46 -0700 Subject: [PATCH 05/17] Add targeting key extraction, Object[] handling, and use Reason constants Extract targetingKey from EvaluationContext.getTargetingKey() explicitly in convertContext. Add Object[] array handling in objectToValue. Replace string literal reasons with Reason.STATIC and Reason.ERROR constants. Co-Authored-By: Claude Opus 4.6 --- .../openfeature/MixpanelProvider.java | 12 ++++++ .../openfeature/MixpanelProviderTest.java | 42 +++++++++++++++++++ 2 files changed, 54 insertions(+) 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 31de57a..345a30d 100644 --- a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java +++ b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java @@ -165,6 +165,10 @@ static Map convertContext(EvaluationContext ctx) { 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)); @@ -256,6 +260,14 @@ private static Value objectToValue(Object obj) { } return new Value(values); } + if (obj instanceof Object[]) { + Object[] arr = (Object[]) obj; + ArrayList values = new ArrayList<>(); + for (Object item : arr) { + values.add(objectToValue(item)); + } + return new Value(values); + } return new Value(obj.toString()); } } 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 92c017a..4351bf5 100644 --- a/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java +++ b/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java @@ -359,6 +359,48 @@ public void testTargetingKeyIsRegularProperty() { assertEquals("user-123", captured.get("distinct_id")); } + @SuppressWarnings("unchecked") + @Test + public void testTargetingKeyFromGetTargetingKeyIsIncluded() { + 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("plan", new Value("pro")); + // ImmutableContext(targetingKey, attributes) sets getTargetingKey() separately from keySet() + provider.getBooleanEvaluation("flag", false, new ImmutableContext("tk-from-constructor", attrs)); + + Map captured = contextCaptor.getValue(); + assertEquals("tk-from-constructor", captured.get("targetingKey")); + assertEquals("user-123", captured.get("distinct_id")); + assertEquals("pro", captured.get("plan")); + } + + @SuppressWarnings("unchecked") + @Test + public void testExplicitTargetingKeyAttributeOverriddenByGetTargetingKey() { + 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("targetingKey", new Value("from-attribute")); + attrs.put("distinct_id", new Value("user-123")); + // ImmutableContext merges the constructor targeting key into keySet(), + // so the constructor value takes precedence over an explicit attribute + provider.getBooleanEvaluation("flag", false, new ImmutableContext("from-constructor", attrs)); + + Map captured = contextCaptor.getValue(); + // The SDK's ImmutableContext uses the constructor targeting key as the + // "targetingKey" entry in keySet(), overriding the explicit attribute + assertEquals("from-constructor", captured.get("targetingKey")); + assertEquals("user-123", captured.get("distinct_id")); + } + // PROVIDER_NOT_READY @Test From a54d5551343bd1fa9acc26c87c110f02c1703702 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 21:21:04 -0700 Subject: [PATCH 06/17] Rename OpenFeature provider artifact and add publishing config Rename artifact from mixpanel-java-openfeature-provider to mixpanel-java-openfeature, give it its own version (0.1.0) independent of the main SDK, and add Maven Central publishing plugins (source, javadoc, GPG signing, central-publishing). Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/pom.xml | 75 ++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/openfeature-provider/pom.xml b/openfeature-provider/pom.xml index bcd885b..e74646f 100644 --- a/openfeature-provider/pom.xml +++ b/openfeature-provider/pom.xml @@ -3,8 +3,8 @@ 4.0.0 com.mixpanel - mixpanel-java-openfeature-provider - 1.7.0 + mixpanel-java-openfeature + 0.1.0 jar Mixpanel Java SDK - OpenFeature Provider @@ -43,8 +43,77 @@ 1.8 + + + central + https://central.sonatype.com/repository/maven-snapshots/ + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.9.0 + true + + central + mixpanel-java-openfeature-${project.version} + false + + + + + org.apache.maven.plugins + maven-source-plugin + 2.2.1 + + + attach-sources + + jar-no-fork + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.9.1 + + + attach-javadocs + + jar + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.4 + + + sign-artifacts + verify + + sign + + + + --pinentry-mode + loopback + + + + + + org.apache.maven.plugins maven-surefire-plugin @@ -57,7 +126,7 @@ com.mixpanel mixpanel-java - ${project.version} + 1.7.0 From 8e144253b741d21ab35cc5fea872fddd7d4be24e Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 21:24:25 -0700 Subject: [PATCH 07/17] Add CI job for OpenFeature provider tests Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4e8d76..77c44ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,43 @@ jobs: name: test-results-java-${{ matrix.java-version }} path: target/surefire-reports/ + test-openfeature-provider: + runs-on: ubuntu-latest + strategy: + matrix: + java-version: ['8', '11', '17', '21'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java-version }} + distribution: 'temurin' + + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Install core library to local Maven repository + run: mvn install -DskipTests + + - name: Run OpenFeature provider tests + run: cd openfeature-provider && mvn clean test + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: openfeature-test-results-java-${{ matrix.java-version }} + path: openfeature-provider/target/surefire-reports/ + code-quality: runs-on: ubuntu-latest From e56a2b6507ebb2ae934a24bf0a8992d1d4dc3b13 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 21:35:07 -0700 Subject: [PATCH 08/17] Pin CI actions to full commit SHAs and skip GPG for install step Org policy requires actions pinned to full-length commit SHAs instead of version tags. Also skip GPG signing when installing the core library locally in the OpenFeature provider job. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77c44ea..25f6c7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,16 +15,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up JDK ${{ matrix.java-version }} - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: java-version: ${{ matrix.java-version }} distribution: 'temurin' - name: Cache Maven dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} @@ -42,7 +42,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: test-results-java-${{ matrix.java-version }} path: target/surefire-reports/ @@ -55,16 +55,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up JDK ${{ matrix.java-version }} - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: java-version: ${{ matrix.java-version }} distribution: 'temurin' - name: Cache Maven dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} @@ -72,14 +72,14 @@ jobs: ${{ runner.os }}-maven- - name: Install core library to local Maven repository - run: mvn install -DskipTests + run: mvn install -DskipTests -Dgpg.skip=true - name: Run OpenFeature provider tests run: cd openfeature-provider && mvn clean test - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: openfeature-test-results-java-${{ matrix.java-version }} path: openfeature-provider/target/surefire-reports/ @@ -89,16 +89,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up JDK 8 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: java-version: '8' distribution: 'temurin' - name: Cache Maven dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} From 8c933a875731448335fd525735ef15ef14225c9d Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 21:59:50 -0700 Subject: [PATCH 09/17] Remove Java 8 from OpenFeature provider CI matrix OpenFeature SDK 1.20.1 requires Java 11+ (class version 55.0), so the provider cannot compile under Java 8. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25f6c7e..313ea60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - java-version: ['8', '11', '17', '21'] + java-version: ['11', '17', '21'] steps: - name: Checkout code From b926f2460ef84e4d068f83d1daa90c1b6e44b34c Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 22:09:27 -0700 Subject: [PATCH 10/17] Add least-privilege permissions to CI workflow Fixes CodeQL actions/missing-workflow-permissions alert by declaring explicit read-only contents permission. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 313ea60..35a4e1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [ master ] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest From d71a05770e9160d04db119a523cba590002da3c9 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 22:15:29 -0700 Subject: [PATCH 11/17] Refactor MixpanelProvider evaluate methods and log shutdown errors Deduplicate getObjectEvaluation and evaluate by extracting shared logic into a common evaluate method that accepts a mapper function. Also log exceptions during shutdown instead of silently ignoring them. Co-Authored-By: Claude Opus 4.6 --- .../openfeature/MixpanelProvider.java | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 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 345a30d..c3f559b 100644 --- a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java +++ b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java @@ -5,6 +5,9 @@ import com.mixpanel.mixpanelapi.featureflags.provider.LocalFlagsProvider; import dev.openfeature.sdk.*; +import java.util.logging.Level; +import java.util.logging.Logger; + import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -13,6 +16,7 @@ public class MixpanelProvider implements FeatureProvider { + private static final Logger logger = Logger.getLogger(MixpanelProvider.class.getName()); private final BaseFlagsProvider flagsProvider; public MixpanelProvider(BaseFlagsProvider flagsProvider) { @@ -46,24 +50,9 @@ public ProviderEvaluation getDoubleEvaluation(String key, Double default @Override public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { - ProviderEvaluation notReadyResult = checkNotReady(defaultValue); - if (notReadyResult != null) { - return notReadyResult; - } - - SelectedVariant result; - try { - result = fetchVariant(key, ctx); - } catch (Exception e) { - return errorResult(defaultValue, ErrorCode.GENERAL, e.getMessage()); - } - - if (result.isFallback()) { - return errorResult(defaultValue, ErrorCode.FLAG_NOT_FOUND, "Flag not found: " + key); - } - - Value value = objectToValue(result.getVariantValue()); - return successResult(value, result.getVariantKey()); + return evaluate(key, defaultValue, ctx, + result -> objectToValue(result.getVariantValue()), + "Expected Value"); } @Override @@ -71,11 +60,19 @@ public void shutdown() { try { flagsProvider.shutdown(); } catch (Exception e) { - // ignore + logger.log(Level.WARNING, "Error shutting down Mixpanel flags provider", e); } } private ProviderEvaluation evaluate(String key, T defaultValue, Class expectedType, EvaluationContext ctx) { + return evaluate(key, defaultValue, ctx, + result -> coerce(result.getVariantValue(), expectedType), + "Expected " + expectedType.getSimpleName()); + } + + private ProviderEvaluation evaluate(String key, T defaultValue, EvaluationContext ctx, + java.util.function.Function, T> mapper, + String typeDescription) { ProviderEvaluation notReadyResult = checkNotReady(defaultValue); if (notReadyResult != null) { return notReadyResult; @@ -92,13 +89,13 @@ private ProviderEvaluation evaluate(String key, T defaultValue, Class return errorResult(defaultValue, ErrorCode.FLAG_NOT_FOUND, "Flag not found: " + key); } - T typedValue = coerce(result.getVariantValue(), expectedType); - if (typedValue == null) { + T value = mapper.apply(result); + if (value == null) { return errorResult(defaultValue, ErrorCode.TYPE_MISMATCH, - "Expected " + expectedType.getSimpleName() + " but got " + result.getVariantValue().getClass().getSimpleName()); + typeDescription + " but got " + result.getVariantValue().getClass().getSimpleName()); } - return successResult(typedValue, result.getVariantKey()); + return successResult(value, result.getVariantKey()); } private SelectedVariant fetchVariant(String key, EvaluationContext ctx) { From a5b1fb4d6b1ec8575705f4bfc042a35657bc0b02 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Mon, 6 Apr 2026 14:01:16 -0700 Subject: [PATCH 12/17] Add simplified constructors for OpenFeature provider Overloaded constructors accept a token and LocalFlagsConfig or RemoteFlagsConfig, create the MixpanelAPI internally, auto-start polling for local configs, and expose the client via getMixpanel(). Co-Authored-By: Claude Opus 4.6 --- .../openfeature/MixpanelProvider.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 c3f559b..ff10e10 100644 --- a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java +++ b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java @@ -1,5 +1,8 @@ package com.mixpanel.openfeature; +import com.mixpanel.mixpanelapi.MixpanelAPI; +import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.config.RemoteFlagsConfig; import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; import com.mixpanel.mixpanelapi.featureflags.provider.BaseFlagsProvider; import com.mixpanel.mixpanelapi.featureflags.provider.LocalFlagsProvider; @@ -18,9 +21,50 @@ public class MixpanelProvider implements FeatureProvider { private static final Logger logger = Logger.getLogger(MixpanelProvider.class.getName()); private final BaseFlagsProvider flagsProvider; + private final MixpanelAPI mixpanel; public MixpanelProvider(BaseFlagsProvider flagsProvider) { this.flagsProvider = flagsProvider; + this.mixpanel = null; + } + + /** + * Constructs a MixpanelProvider with local feature flags evaluation. + * Creates a MixpanelAPI instance, extracts the local flags provider, + * and automatically starts polling for flag definitions. + * + * @param token the Mixpanel project token (unused, token is read from config) + * @param config configuration for local feature flags evaluation + */ + public MixpanelProvider(String token, LocalFlagsConfig config) { + MixpanelAPI api = new MixpanelAPI(config); + this.mixpanel = api; + LocalFlagsProvider localFlags = api.getLocalFlags(); + localFlags.startPollingForDefinitions(); + this.flagsProvider = localFlags; + } + + /** + * Constructs a MixpanelProvider with remote feature flags evaluation. + * Creates a MixpanelAPI instance and extracts the remote flags provider. + * + * @param token the Mixpanel project token (unused, token is read from config) + * @param config configuration for remote feature flags evaluation + */ + public MixpanelProvider(String token, RemoteFlagsConfig config) { + MixpanelAPI api = new MixpanelAPI(config); + this.mixpanel = api; + this.flagsProvider = api.getRemoteFlags(); + } + + /** + * Returns the MixpanelAPI instance used by this provider, or null if the provider + * was constructed directly with a BaseFlagsProvider. + * + * @return the MixpanelAPI instance, or null + */ + public MixpanelAPI getMixpanel() { + return mixpanel; } @Override From 1a8c8d0e76923001ddc34ffda8651b617fd0342e Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Tue, 7 Apr 2026 14:18:23 -0700 Subject: [PATCH 13/17] Use TARGETING_MATCH reason for successful evaluations and DEFAULT for flag not found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates OpenFeature provider reason codes to match the updated spec: - Success: STATIC → TARGETING_MATCH - Flag not found: ERROR → DEFAULT Co-Authored-By: Claude Opus 4.6 --- .../openfeature/MixpanelProvider.java | 5 +-- .../openfeature/MixpanelProviderTest.java | 34 +++++++++---------- 2 files changed, 20 insertions(+), 19 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 ff10e10..ecb479b 100644 --- a/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java +++ b/openfeature-provider/src/main/java/com/mixpanel/openfeature/MixpanelProvider.java @@ -148,9 +148,10 @@ private SelectedVariant fetchVariant(String key, EvaluationContext ctx) } private ProviderEvaluation errorResult(T defaultValue, ErrorCode errorCode, String errorMessage) { + String reason = errorCode == ErrorCode.FLAG_NOT_FOUND ? "DEFAULT" : "ERROR"; return ProviderEvaluation.builder() .value(defaultValue) - .reason("ERROR") + .reason(reason) .errorCode(errorCode) .errorMessage(errorMessage) .build(); @@ -160,7 +161,7 @@ private ProviderEvaluation successResult(T value, String variantKey) { return ProviderEvaluation.builder() .value(value) .variant(variantKey) - .reason("STATIC") + .reason("TARGETING_MATCH") .build(); } 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 4351bf5..c5870bd 100644 --- a/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java +++ b/openfeature-provider/src/test/java/com/mixpanel/openfeature/MixpanelProviderTest.java @@ -49,7 +49,7 @@ public void testBooleanEvaluationSuccess() { assertTrue(result.getValue()); assertEquals("on", result.getVariant()); - assertEquals("STATIC", result.getReason()); + assertEquals("TARGETING_MATCH", result.getReason()); assertNull(result.getErrorCode()); } @@ -78,7 +78,7 @@ public void testBooleanEvaluationFlagNotFound() { assertFalse(result.getValue()); assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); - assertEquals("ERROR", result.getReason()); + assertEquals("DEFAULT", result.getReason()); } // String evaluation @@ -94,7 +94,7 @@ public void testStringEvaluationSuccess() { assertEquals("blue", result.getValue()); assertEquals("blue", result.getVariant()); - assertEquals("STATIC", result.getReason()); + assertEquals("TARGETING_MATCH", result.getReason()); assertNull(result.getErrorCode()); } @@ -123,7 +123,7 @@ public void testStringEvaluationFlagNotFound() { assertEquals("fallback", result.getValue()); assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); - assertEquals("ERROR", result.getReason()); + assertEquals("DEFAULT", result.getReason()); } // Integer evaluation @@ -139,7 +139,7 @@ public void testIntegerEvaluationSuccess() { assertEquals(Integer.valueOf(42), result.getValue()); assertEquals("v1", result.getVariant()); - assertEquals("STATIC", result.getReason()); + assertEquals("TARGETING_MATCH", result.getReason()); assertNull(result.getErrorCode()); } @@ -153,7 +153,7 @@ public void testIntegerEvaluationFromLong() { ProviderEvaluation result = provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext()); assertEquals(Integer.valueOf(42), result.getValue()); - assertEquals("STATIC", result.getReason()); + assertEquals("TARGETING_MATCH", result.getReason()); } @SuppressWarnings("unchecked") @@ -166,7 +166,7 @@ public void testIntegerEvaluationFromDouble() { ProviderEvaluation result = provider.getIntegerEvaluation("int-flag", 0, new ImmutableContext()); assertEquals(Integer.valueOf(42), result.getValue()); - assertEquals("STATIC", result.getReason()); + assertEquals("TARGETING_MATCH", result.getReason()); } @SuppressWarnings("unchecked") @@ -194,7 +194,7 @@ public void testIntegerEvaluationFlagNotFound() { assertEquals(Integer.valueOf(99), result.getValue()); assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); - assertEquals("ERROR", result.getReason()); + assertEquals("DEFAULT", result.getReason()); } @SuppressWarnings("unchecked") @@ -223,7 +223,7 @@ public void testDoubleEvaluationSuccess() { assertEquals(Double.valueOf(3.14), result.getValue()); assertEquals("v1", result.getVariant()); - assertEquals("STATIC", result.getReason()); + assertEquals("TARGETING_MATCH", result.getReason()); assertNull(result.getErrorCode()); } @@ -237,7 +237,7 @@ public void testDoubleEvaluationFromInteger() { ProviderEvaluation result = provider.getDoubleEvaluation("double-flag", 0.0, new ImmutableContext()); assertEquals(Double.valueOf(42.0), result.getValue()); - assertEquals("STATIC", result.getReason()); + assertEquals("TARGETING_MATCH", result.getReason()); } @SuppressWarnings("unchecked") @@ -265,7 +265,7 @@ public void testDoubleEvaluationFlagNotFound() { assertEquals(Double.valueOf(9.9), result.getValue()); assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); - assertEquals("ERROR", result.getReason()); + assertEquals("DEFAULT", result.getReason()); } // Object evaluation @@ -283,7 +283,7 @@ public void testObjectEvaluationSuccess() { assertNotNull(result.getValue()); assertEquals("v1", result.getVariant()); - assertEquals("STATIC", result.getReason()); + assertEquals("TARGETING_MATCH", result.getReason()); assertNull(result.getErrorCode()); } @@ -299,7 +299,7 @@ public void testObjectEvaluationFlagNotFound() { assertEquals(defaultValue, result.getValue()); assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); - assertEquals("ERROR", result.getReason()); + assertEquals("DEFAULT", result.getReason()); } // Context handling — merged context from ctx parameter is forwarded @@ -443,7 +443,7 @@ public void testProviderReadyWithLocalProvider() { ProviderEvaluation result = localProvider.getBooleanEvaluation("flag", false, new ImmutableContext()); assertTrue(result.getValue()); - assertEquals("STATIC", result.getReason()); + assertEquals("TARGETING_MATCH", result.getReason()); assertNull(result.getErrorCode()); } @@ -458,7 +458,7 @@ public void testProviderNotReadySkippedForNonLocalProvider() { ProviderEvaluation result = provider.getBooleanEvaluation("flag", false, new ImmutableContext()); assertTrue(result.getValue()); - assertEquals("STATIC", result.getReason()); + assertEquals("TARGETING_MATCH", result.getReason()); assertNull(result.getErrorCode()); } @@ -530,7 +530,7 @@ public void testNullVariantKeyTreatedAsFallbackOnBooleanEvaluation() { assertFalse(result.getValue()); assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); - assertEquals("ERROR", result.getReason()); + assertEquals("DEFAULT", result.getReason()); } @SuppressWarnings("unchecked") @@ -545,7 +545,7 @@ public void testNullVariantKeyTreatedAsFallbackOnObjectEvaluation() { assertEquals(defaultValue, result.getValue()); assertEquals(ErrorCode.FLAG_NOT_FOUND, result.getErrorCode()); - assertEquals("ERROR", result.getReason()); + assertEquals("DEFAULT", result.getReason()); } // Shutdown From 01f9b8a51fb7d51089169bdff58497f52f7522d5 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 9 Apr 2026 13:10:53 -0700 Subject: [PATCH 14/17] Add separate release workflow for OpenFeature provider Adds a manual-dispatch GitHub Actions workflow to publish the OpenFeature provider to Maven Central independently from the core SDK release cycle. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release-openfeature.yml | 107 ++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .github/workflows/release-openfeature.yml diff --git a/.github/workflows/release-openfeature.yml b/.github/workflows/release-openfeature.yml new file mode 100644 index 0000000..5932285 --- /dev/null +++ b/.github/workflows/release-openfeature.yml @@ -0,0 +1,107 @@ +name: Release OpenFeature Provider to Maven Central + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.1.0)' + required: true + type: string + +jobs: + release: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.set-version.outputs.version }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + java-version: '8' + distribution: 'temurin' + + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Import GPG key + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: | + echo "$GPG_PRIVATE_KEY" | base64 --decode | gpg --batch --import + echo "allow-preset-passphrase" >> ~/.gnupg/gpg-agent.conf + echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf + gpg --list-secret-keys --keyid-format LONG + + - name: Configure Maven settings + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + run: | + mkdir -p ~/.m2 + cat > ~/.m2/settings.xml << EOF + + + + central + ${MAVEN_CENTRAL_USERNAME} + ${MAVEN_CENTRAL_TOKEN} + + + + EOF + + - name: Set version + id: set-version + run: | + VERSION=${{ github.event.inputs.version }} + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "version=$VERSION" >> $GITHUB_OUTPUT + cd openfeature-provider + mvn versions:set -DnewVersion=$VERSION -DgenerateBackupPoms=false + + - name: Build and install Main SDK locally + run: mvn clean install -DskipTests -Dgpg.skip=true + + - name: Run tests - OpenFeature Provider + run: | + cd openfeature-provider + mvn clean test + + - name: Deploy OpenFeature Provider to Maven Central + env: + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: | + cd openfeature-provider + mvn clean deploy -Dgpg.passphrase=$GPG_PASSPHRASE + + verify: + needs: release + runs-on: ubuntu-latest + if: success() + + steps: + - name: Wait for Maven Central sync + run: sleep 300 # Wait 5 minutes for synchronization + + - name: Verify artifact on Maven Central + run: | + VERSION=${{ needs.release.outputs.version }} + + RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" https://repo1.maven.org/maven2/com/mixpanel/mixpanel-java-openfeature/${VERSION}/mixpanel-java-openfeature-${VERSION}.jar) + if [ $RESPONSE -eq 200 ]; then + echo "✅ OpenFeature Provider successfully published to Maven Central" + else + echo "⚠️ OpenFeature Provider not yet available on Maven Central (HTTP $RESPONSE)" + fi + + echo "Note: Artifacts may take up to 30 minutes to appear on Maven Central" From 7aa2ad0bf8efa52d44a8f8c4a8616e24b5375a65 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 9 Apr 2026 13:11:41 -0700 Subject: [PATCH 15/17] Add release documentation for OpenFeature provider Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/RELEASE.md | 53 +++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 openfeature-provider/RELEASE.md diff --git a/openfeature-provider/RELEASE.md b/openfeature-provider/RELEASE.md new file mode 100644 index 0000000..02de7a0 --- /dev/null +++ b/openfeature-provider/RELEASE.md @@ -0,0 +1,53 @@ +# Releasing the OpenFeature Provider + +The OpenFeature provider (`com.mixpanel:mixpanel-java-openfeature`) is published to Maven Central independently from the core SDK. + +## Prerequisites + +The following GitHub secrets must be configured (shared with the core SDK release workflow): + +- `GPG_PRIVATE_KEY` — Base64-encoded GPG private key +- `GPG_PASSPHRASE` — GPG key passphrase +- `MAVEN_CENTRAL_USERNAME` — Maven Central Portal username +- `MAVEN_CENTRAL_TOKEN` — Maven Central Portal token + +## Releasing via GitHub Actions + +1. Go to **Actions** > **Release OpenFeature Provider to Maven Central** +2. Click **Run workflow** +3. Enter the version to release (e.g., `0.1.0`) +4. The workflow will: + - Build and install the core SDK locally + - Run OpenFeature provider tests + - Sign artifacts with GPG + - Deploy to Maven Central Portal + - Wait 5 minutes then verify the artifact is available + +After deployment, artifacts are visible at: +- Deployments: https://central.sonatype.com/publishing/deployments +- Published: https://central.sonatype.com/artifact/com.mixpanel/mixpanel-java-openfeature + +Note: `autoPublish` is set to `false` in `pom.xml`, so you may need to manually publish the deployment from the Sonatype Central Portal. + +## Releasing manually + +```bash +# 1. Set the version +cd openfeature-provider +mvn versions:set -DnewVersion=0.1.0 -DgenerateBackupPoms=false + +# 2. Build and install the core SDK locally +cd .. +mvn clean install -DskipTests -Dgpg.skip=true + +# 3. Run tests +cd openfeature-provider +mvn clean test + +# 4. Deploy +mvn clean deploy -Dgpg.passphrase= +``` + +## Versioning + +The OpenFeature provider is versioned independently from the core SDK. The current core SDK dependency version is pinned in `pom.xml` — update it when the provider needs features from a newer core SDK release. From 4aabe78b250dfdcca2794a4088b8dcda54483b96 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 9 Apr 2026 13:49:52 -0700 Subject: [PATCH 16/17] Bump core SDK version to 1.8.0 and update OpenFeature provider dependency Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/pom.xml | 2 +- pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openfeature-provider/pom.xml b/openfeature-provider/pom.xml index e74646f..bc7e082 100644 --- a/openfeature-provider/pom.xml +++ b/openfeature-provider/pom.xml @@ -126,7 +126,7 @@ com.mixpanel mixpanel-java - 1.7.0 + 1.8.0 diff --git a/pom.xml b/pom.xml index 110485e..eabcacd 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ com.mixpanel mixpanel-java - 1.7.0 + 1.8.0 jar mixpanel-java From 3ea8e85ac30b1a52e80ba9d02402ac54a8ba5a9b Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 9 Apr 2026 14:20:16 -0700 Subject: [PATCH 17/17] Add README for OpenFeature provider Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/README.md | 307 +++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 openfeature-provider/README.md diff --git a/openfeature-provider/README.md b/openfeature-provider/README.md new file mode 100644 index 0000000..a781b13 --- /dev/null +++ b/openfeature-provider/README.md @@ -0,0 +1,307 @@ +# Mixpanel Java OpenFeature Provider + +[![Maven Central](https://img.shields.io/maven-central/v/com.mixpanel/mixpanel-java-openfeature.svg)](https://central.sonatype.com/artifact/com.mixpanel/mixpanel-java-openfeature) +[![OpenFeature](https://img.shields.io/badge/OpenFeature-compatible-green)](https://openfeature.dev/) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE) + +An [OpenFeature](https://openfeature.dev/) provider that integrates Mixpanel's feature flags with the OpenFeature Java SDK. This allows you to use Mixpanel's feature flagging capabilities through OpenFeature's standardized, vendor-agnostic API. + +## Overview + +This package provides a bridge between Mixpanel's native feature flags implementation and the OpenFeature specification. By using this provider, you can: + +- Leverage Mixpanel's powerful feature flag and experimentation platform +- Use OpenFeature's standardized API for flag evaluation +- Easily switch between feature flag providers without changing your application code +- Integrate with OpenFeature's ecosystem of tools and frameworks + +## Installation + +### Maven + +```xml + + com.mixpanel + mixpanel-java-openfeature + 0.1.0 + +``` + +### Gradle + +```groovy +implementation 'com.mixpanel:mixpanel-java-openfeature:0.1.0' +``` + +You will also need the OpenFeature Java SDK: + +```xml + + dev.openfeature + sdk + 1.20.1 + +``` + +## Quick Start + +```java +import com.mixpanel.openfeature.MixpanelProvider; +import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Client; + +// 1. Create and register the provider with local evaluation +MixpanelProvider provider = new MixpanelProvider( + "YOUR_PROJECT_TOKEN", + new LocalFlagsConfig("YOUR_PROJECT_TOKEN") +); +OpenFeatureAPI api = OpenFeatureAPI.getInstance(); +api.setProvider(provider); + +// 2. Get a client and evaluate flags +Client client = api.getClient(); +boolean showNewFeature = client.getBooleanValue("new-feature-flag", false); + +if (showNewFeature) { + System.out.println("New feature is enabled!"); +} +``` + +## Initialization + +The provider supports three constructors depending on your evaluation strategy: + +### Local Evaluation + +Evaluates flags locally using cached flag definitions that are polled from Mixpanel. This is the recommended approach for most server-side applications as it minimizes latency. + +```java +MixpanelProvider provider = new MixpanelProvider( + "YOUR_PROJECT_TOKEN", + new LocalFlagsConfig("YOUR_PROJECT_TOKEN") +); +``` + +This automatically starts polling for flag definitions in the background. + +### Remote Evaluation + +Evaluates flags by making a request to Mixpanel's servers for each evaluation. Use this when you need real-time flag values and can tolerate the additional network latency. + +```java +MixpanelProvider provider = new MixpanelProvider( + "YOUR_PROJECT_TOKEN", + new RemoteFlagsConfig("YOUR_PROJECT_TOKEN") +); +``` + +### Using an Existing MixpanelAPI Instance + +If your application already has a `MixpanelAPI` instance configured, you can create the provider from its flags provider directly rather than having the provider create a new one: + +```java +// Your existing MixpanelAPI instance +MixpanelAPI mixpanel = new MixpanelAPI(new LocalFlagsConfig("YOUR_PROJECT_TOKEN")); +LocalFlagsProvider localFlags = mixpanel.getLocalFlags(); +localFlags.startPollingForDefinitions(); + +// Wrap the existing flags provider with OpenFeature +MixpanelProvider provider = new MixpanelProvider(localFlags); +``` + +> **Note:** When using this constructor, `provider.getMixpanel()` will return `null` since the provider does not own the `MixpanelAPI` instance. + +## Usage Examples + +### Basic Boolean Flag + +```java +Client client = api.getClient(); + +// Get a boolean flag with a default value +boolean isFeatureEnabled = client.getBooleanValue("my-feature", false); + +if (isFeatureEnabled) { + // Show the new feature +} +``` + +### Mixpanel Flag Types and OpenFeature Evaluation Methods + +Mixpanel feature flags support three flag types. Use the corresponding OpenFeature evaluation method based on your flag's variant values: + +| Mixpanel Flag Type | Variant Values | OpenFeature Method | +|---|---|---| +| Feature Gate | `true` / `false` | `getBooleanValue()` | +| Experiment | boolean, string, number, or JSON object | `getBooleanValue()`, `getStringValue()`, `getIntegerValue()`, `getDoubleValue()`, or `getObjectValue()` | +| Dynamic Config | JSON object | `getObjectValue()` | + +```java +Client client = api.getClient(); + +// Feature Gate - boolean variants +boolean isFeatureOn = client.getBooleanValue("new-checkout", false); + +// Experiment with string variants +String buttonColor = client.getStringValue("button-color-test", "blue"); + +// Experiment with integer variants +int maxItems = client.getIntegerValue("max-items", 10); + +// Experiment with double variants +double threshold = client.getDoubleValue("score-threshold", 0.5); + +// Dynamic Config - JSON object variants +Value featureConfig = client.getObjectValue("homepage-layout", new Value("default")); +``` + +### Getting Full Resolution Details + +If you need additional metadata about the flag evaluation: + +```java +Client client = api.getClient(); + +FlagEvaluationDetails details = client.getBooleanDetails("my-feature", false); + +System.out.println(details.getValue()); // The resolved value +System.out.println(details.getVariant()); // The variant key from Mixpanel +System.out.println(details.getReason()); // Why this value was returned +System.out.println(details.getErrorCode()); // Error code if evaluation failed +``` + +### Setting Context + +You can pass evaluation context that will be sent to Mixpanel for flag evaluation: + +```java +MutableContext context = new MutableContext(); +context.setTargetingKey("user-123"); +context.add("email", "user@example.com"); +context.add("plan", "premium"); +context.add("beta_tester", true); + +boolean value = client.getBooleanValue("premium-feature", false, context); +``` + +### Accessing the Underlying MixpanelAPI + +If you initialized the provider with a token and config, you can access the underlying `MixpanelAPI` instance for sending events or profile updates: + +```java +MixpanelAPI mixpanel = provider.getMixpanel(); +``` + +> **Note:** This returns `null` if the provider was constructed with a `BaseFlagsProvider` directly. + +### Shutdown + +When your application is shutting down, call `shutdown()` to clean up resources: + +```java +provider.shutdown(); +``` + +## Context Mapping + +### All Properties Passed Directly + +All properties in the OpenFeature `EvaluationContext` are passed directly to Mixpanel's feature flag evaluation. There is no transformation or filtering of properties. + +```java +// This OpenFeature context... +MutableContext context = new MutableContext(); +context.setTargetingKey("user-123"); +context.add("email", "user@example.com"); +context.add("plan", "premium"); + +// ...is passed to Mixpanel as-is for flag evaluation +``` + +### targetingKey is Not Special + +Unlike some feature flag providers, `targetingKey` is **not** used as a special bucketing key in Mixpanel. It is simply passed as another context property. Mixpanel's server-side configuration determines which properties are used for targeting rules and bucketing. + +## Error Handling + +The provider uses OpenFeature's standard error codes to indicate issues during flag evaluation: + +### PROVIDER_NOT_READY + +Returned when flags are evaluated before the local flags provider has finished loading flag definitions. This only applies when using local evaluation. + +```java +FlagEvaluationDetails details = client.getBooleanDetails("my-feature", false); + +if (details.getErrorCode() == ErrorCode.PROVIDER_NOT_READY) { + System.out.println("Provider still loading, using default value"); +} +``` + +### FLAG_NOT_FOUND + +Returned when the requested flag does not exist in Mixpanel. + +```java +FlagEvaluationDetails details = client.getBooleanDetails("nonexistent-flag", false); + +if (details.getErrorCode() == ErrorCode.FLAG_NOT_FOUND) { + System.out.println("Flag does not exist, using default value"); +} +``` + +### TYPE_MISMATCH + +Returned when the flag value type does not match the requested type. The provider supports some numeric coercions (e.g., a `Long` flag value can be retrieved via `getIntegerValue()` if it fits within `Integer` bounds, and any numeric type can be retrieved via `getDoubleValue()`), but incompatible types will return this error. + +```java +// If 'my-flag' is configured as a string in Mixpanel... +FlagEvaluationDetails details = client.getBooleanDetails("my-flag", false); + +if (details.getErrorCode() == ErrorCode.TYPE_MISMATCH) { + System.out.println("Flag is not a boolean, using default value"); +} +``` + +## Troubleshooting + +### Flags Always Return Default Values + +**Possible causes:** + +1. **Provider not ready (local evaluation):** The local flags provider may still be loading flag definitions. Flag definitions are polled asynchronously after the provider is created. Allow time for the initial fetch to complete, or check the `PROVIDER_NOT_READY` error code. + +2. **Invalid project token:** Verify the token passed to the config matches your Mixpanel project. + +3. **Flag not configured:** Verify the flag exists in your Mixpanel project and is enabled. + +4. **Network issues:** Check that your application can reach Mixpanel's API servers. + +### Type Mismatch Errors + +If you are getting `TYPE_MISMATCH` errors: + +1. **Check flag configuration:** Verify the flag's value type in Mixpanel matches how you are evaluating it. For example, if the flag value is the string `"true"`, use `getStringValue()`, not `getBooleanValue()`. + +2. **Use `getObjectValue()` for complex types:** For JSON objects or arrays, use `getObjectValue()`. + +3. **Numeric coercion:** Integer evaluation accepts `Long` and whole-number `Double` values within `Integer` bounds. Double evaluation accepts any numeric type. + +### Exposure Events Not Tracking + +If `$experiment_started` events are not appearing in Mixpanel: + +1. **Verify Mixpanel tracking is working:** Test that other Mixpanel events are being tracked successfully. + +2. **Check for duplicate evaluations:** Mixpanel only tracks the first exposure per flag per session to avoid duplicate events. + +## Requirements + +- Java 8 or higher +- `mixpanel-java` 1.8.0+ +- OpenFeature SDK 1.20.1+ + +## License + +Apache-2.0