diff --git a/src/main/java/dev/openfeature/sdk/Client.java b/src/main/java/dev/openfeature/sdk/Client.java index 7b41b9b07..441d31e2b 100644 --- a/src/main/java/dev/openfeature/sdk/Client.java +++ b/src/main/java/dev/openfeature/sdk/Client.java @@ -5,17 +5,19 @@ /** * Interface used to resolve flags of varying types. */ -public interface Client extends Features, EventBus { +public interface Client extends Features, Tracking, EventBus { ClientMetadata getMetadata(); /** * Return an optional client-level evaluation context. + * * @return {@link EvaluationContext} */ EvaluationContext getEvaluationContext(); /** * Set the client-level evaluation context. + * * @param ctx Client level context. */ Client setEvaluationContext(EvaluationContext ctx); @@ -30,12 +32,14 @@ public interface Client extends Features, EventBus { /** * Fetch the hooks associated to this client. + * * @return A list of {@link Hook}s. */ List getHooks(); /** * Returns the current state of the associated provider. + * * @return the provider state */ ProviderState getProviderState(); diff --git a/src/main/java/dev/openfeature/sdk/FeatureProvider.java b/src/main/java/dev/openfeature/sdk/FeatureProvider.java index f73b6cdfa..0afaf6014 100644 --- a/src/main/java/dev/openfeature/sdk/FeatureProvider.java +++ b/src/main/java/dev/openfeature/sdk/FeatureProvider.java @@ -1,5 +1,6 @@ package dev.openfeature.sdk; +import javax.annotation.Nullable; import java.util.ArrayList; import java.util.List; @@ -71,4 +72,14 @@ default ProviderState getState() { return ProviderState.READY; } + /** + * Feature provider implementations can opt in for to support Tracking by implementing this method. + * + * @param eventName The name of the tracking event + * @param context Evaluation context used in flag evaluation (Optional) + * @param details Data pertinent to a particular tracking event (Optional) + */ + default void track(String eventName, @Nullable EvaluationContext context, @Nullable TrackingEventDetails details) { + + } } diff --git a/src/main/java/dev/openfeature/sdk/MutableTrackingEventDetails.java b/src/main/java/dev/openfeature/sdk/MutableTrackingEventDetails.java new file mode 100644 index 000000000..dc59800dd --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/MutableTrackingEventDetails.java @@ -0,0 +1,45 @@ +package dev.openfeature.sdk; + +import dev.openfeature.sdk.internal.ExcludeFromGeneratedCoverageReport; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import lombok.experimental.Delegate; + +import java.util.Map; +import java.util.function.Function; + +/** + * MutableTrackingEventDetails represents data pertinent to a particular tracking event. + */ +@EqualsAndHashCode +@ToString +public class MutableTrackingEventDetails implements TrackingEventDetails { + + + @Getter + private final float target; + @Delegate(excludes = MutableTrackingEventDetails.DelegateExclusions.class) + private final MutableStructure structure; + + public MutableTrackingEventDetails() { + this.target = 0f; + this.structure = new MutableStructure(); + } + + public MutableTrackingEventDetails(final float target) { + this.target = target; + this.structure = new MutableStructure(); + } + + + @SuppressWarnings("all") + private static class DelegateExclusions { + @ExcludeFromGeneratedCoverageReport + public Map merge(Function, Structure> newStructure, + Map base, + Map overriding) { + return null; + } + } +} diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java index f56df15a0..e85840f83 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java @@ -1,13 +1,5 @@ package dev.openfeature.sdk; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.List; -import java.util.function.Consumer; - import dev.openfeature.sdk.exceptions.ExceptionUtils; import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.GeneralError; @@ -19,6 +11,16 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + /** * OpenFeature Client implementation. * You should not instantiate this or reference this class. @@ -28,8 +30,8 @@ * @deprecated // TODO: eventually we will make this non-public. See issue #872 */ @Slf4j -@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.BeanMembersShouldSerialize", "PMD.UnusedLocalVariable", - "unchecked", "rawtypes" }) +@SuppressWarnings({"PMD.DataflowAnomalyAnalysis", "PMD.BeanMembersShouldSerialize", "PMD.UnusedLocalVariable", + "unchecked", "rawtypes"}) @Deprecated() // TODO: eventually we will make this non-public. See issue #872 public class OpenFeatureClient implements Client { @@ -67,11 +69,56 @@ public OpenFeatureClient( this.hookSupport = new HookSupport(); } + /** + * {@inheritDoc} + */ @Override public ProviderState getProviderState() { return openfeatureApi.getFeatureProviderStateManager(domain).getState(); } + /** + * {@inheritDoc} + */ + @Override + public void track(String trackingEventName) { + Objects.requireNonNull(trackingEventName); + invokeTrack(trackingEventName, null, null); + } + + + /** + * {@inheritDoc} + */ + @Override + public void track(String trackingEventName, EvaluationContext context) { + Objects.requireNonNull(trackingEventName); + Objects.requireNonNull(context); + invokeTrack(trackingEventName, context, null); + } + + /** + * {@inheritDoc} + */ + @Override + public void track(String trackingEventName, TrackingEventDetails details) { + Objects.requireNonNull(trackingEventName); + Objects.requireNonNull(details); + invokeTrack(trackingEventName, null, details); + } + + /** + * {@inheritDoc} + */ + @Override + public void track(String trackingEventName, EvaluationContext context, TrackingEventDetails details) { + Objects.requireNonNull(trackingEventName); + Objects.requireNonNull(context); + Objects.requireNonNull(details); + invokeTrack(trackingEventName, mergeEvaluationContext(context), details); + } + + /** * {@inheritDoc} */ @@ -115,7 +162,7 @@ public EvaluationContext getEvaluationContext() { } private FlagEvaluationDetails evaluateFlag(FlagValueType type, String key, T defaultValue, - EvaluationContext ctx, FlagEvaluationOptions options) { + EvaluationContext ctx, FlagEvaluationOptions options) { FlagEvaluationOptions flagOptions = ObjectUtils.defaultIfNull(options, () -> FlagEvaluationOptions.builder().build()); Map hints = Collections.unmodifiableMap(flagOptions.getHookHints()); @@ -183,6 +230,17 @@ private static void enrichDetailsWithErrorDefaults(T defaultValue, FlagEvalu details.setReason(Reason.ERROR.toString()); } + private void invokeTrack(String trackingEventName, + @Nullable EvaluationContext context, + @Nullable TrackingEventDetails details) { + if ("".equals(trackingEventName)) { + throw new IllegalArgumentException("trackingEventName cannot be empty"); + } + openfeatureApi.getFeatureProviderStateManager(domain) + .getProvider() + .track(trackingEventName, mergeEvaluationContext(context), details); + } + /** * Merge invocation contexts with API, transaction and client contexts. * Does not merge before context. @@ -244,7 +302,7 @@ public Boolean getBooleanValue(String key, Boolean defaultValue, EvaluationConte @Override public Boolean getBooleanValue(String key, Boolean defaultValue, EvaluationContext ctx, - FlagEvaluationOptions options) { + FlagEvaluationOptions options) { return getBooleanDetails(key, defaultValue, ctx, options).getValue(); } @@ -260,7 +318,7 @@ public FlagEvaluationDetails getBooleanDetails(String key, Boolean defa @Override public FlagEvaluationDetails getBooleanDetails(String key, Boolean defaultValue, EvaluationContext ctx, - FlagEvaluationOptions options) { + FlagEvaluationOptions options) { return this.evaluateFlag(FlagValueType.BOOLEAN, key, defaultValue, ctx, options); } @@ -276,7 +334,7 @@ public String getStringValue(String key, String defaultValue, EvaluationContext @Override public String getStringValue(String key, String defaultValue, EvaluationContext ctx, - FlagEvaluationOptions options) { + FlagEvaluationOptions options) { return getStringDetails(key, defaultValue, ctx, options).getValue(); } @@ -292,7 +350,7 @@ public FlagEvaluationDetails getStringDetails(String key, String default @Override public FlagEvaluationDetails getStringDetails(String key, String defaultValue, EvaluationContext ctx, - FlagEvaluationOptions options) { + FlagEvaluationOptions options) { return this.evaluateFlag(FlagValueType.STRING, key, defaultValue, ctx, options); } @@ -308,7 +366,7 @@ public Integer getIntegerValue(String key, Integer defaultValue, EvaluationConte @Override public Integer getIntegerValue(String key, Integer defaultValue, EvaluationContext ctx, - FlagEvaluationOptions options) { + FlagEvaluationOptions options) { return getIntegerDetails(key, defaultValue, ctx, options).getValue(); } @@ -324,7 +382,7 @@ public FlagEvaluationDetails getIntegerDetails(String key, Integer defa @Override public FlagEvaluationDetails getIntegerDetails(String key, Integer defaultValue, EvaluationContext ctx, - FlagEvaluationOptions options) { + FlagEvaluationOptions options) { return this.evaluateFlag(FlagValueType.INTEGER, key, defaultValue, ctx, options); } @@ -340,7 +398,7 @@ public Double getDoubleValue(String key, Double defaultValue, EvaluationContext @Override public Double getDoubleValue(String key, Double defaultValue, EvaluationContext ctx, - FlagEvaluationOptions options) { + FlagEvaluationOptions options) { return this.evaluateFlag(FlagValueType.DOUBLE, key, defaultValue, ctx, options).getValue(); } @@ -356,7 +414,7 @@ public FlagEvaluationDetails getDoubleDetails(String key, Double default @Override public FlagEvaluationDetails getDoubleDetails(String key, Double defaultValue, EvaluationContext ctx, - FlagEvaluationOptions options) { + FlagEvaluationOptions options) { return this.evaluateFlag(FlagValueType.DOUBLE, key, defaultValue, ctx, options); } @@ -372,7 +430,7 @@ public Value getObjectValue(String key, Value defaultValue, EvaluationContext ct @Override public Value getObjectValue(String key, Value defaultValue, EvaluationContext ctx, - FlagEvaluationOptions options) { + FlagEvaluationOptions options) { return getObjectDetails(key, defaultValue, ctx, options).getValue(); } @@ -383,13 +441,13 @@ public FlagEvaluationDetails getObjectDetails(String key, Value defaultVa @Override public FlagEvaluationDetails getObjectDetails(String key, Value defaultValue, - EvaluationContext ctx) { + EvaluationContext ctx) { return getObjectDetails(key, defaultValue, ctx, FlagEvaluationOptions.builder().build()); } @Override public FlagEvaluationDetails getObjectDetails(String key, Value defaultValue, EvaluationContext ctx, - FlagEvaluationOptions options) { + FlagEvaluationOptions options) { return this.evaluateFlag(FlagValueType.OBJECT, key, defaultValue, ctx, options); } diff --git a/src/main/java/dev/openfeature/sdk/Tracking.java b/src/main/java/dev/openfeature/sdk/Tracking.java new file mode 100644 index 000000000..7748b7954 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/Tracking.java @@ -0,0 +1,38 @@ +package dev.openfeature.sdk; + +/** + * Interface for Tracking events. + */ +public interface Tracking { + /** + * Performs tracking of a particular action or application state. + * + * @param trackingEventName Event name to track + */ + void track(String trackingEventName); + + /** + * Performs tracking of a particular action or application state. + * + * @param trackingEventName Event name to track + * @param context Evaluation context used in flag evaluation + */ + void track(String trackingEventName, EvaluationContext context); + + /** + * Performs tracking of a particular action or application state. + * + * @param trackingEventName Event name to track + * @param details Data pertinent to a particular tracking event + */ + void track(String trackingEventName, TrackingEventDetails details); + + /** + * Performs tracking of a particular action or application state. + * + * @param trackingEventName Event name to track + * @param context Evaluation context used in flag evaluation + * @param details Data pertinent to a particular tracking event + */ + void track(String trackingEventName, EvaluationContext context, TrackingEventDetails details); +} diff --git a/src/main/java/dev/openfeature/sdk/TrackingEventDetails.java b/src/main/java/dev/openfeature/sdk/TrackingEventDetails.java new file mode 100644 index 000000000..76b20fbbf --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/TrackingEventDetails.java @@ -0,0 +1,7 @@ +package dev.openfeature.sdk; + +/** + * Data pertinent to a particular tracking event. + */ +public interface TrackingEventDetails extends Structure { +} diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java index 23c758e9f..026170a78 100644 --- a/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java +++ b/src/test/java/dev/openfeature/sdk/OpenFeatureAPITest.java @@ -12,6 +12,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; class OpenFeatureAPITest { @@ -101,4 +104,21 @@ void getStateReturnsTheStateOfTheAppropriateProvider() throws Exception { assertThat(OpenFeatureAPI.getInstance().getClient(domain).getProviderState()) .isEqualTo(ProviderState.READY); } + + + @Test + void featureProviderTrackIsCalled() throws Exception { + FeatureProvider featureProvider = mock(FeatureProvider.class); + FeatureProviderTestUtils.setFeatureProvider(featureProvider); + + OpenFeatureAPI.getInstance() + .getClient() + .track("track-event", + new ImmutableContext(), + new MutableTrackingEventDetails(22.2f)); + + verify(featureProvider).initialize(any()); + verify(featureProvider).getMetadata(); + verify(featureProvider).track(any(), any(), any()); + } } diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java index 69d38a486..3c82fd656 100644 --- a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java +++ b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java @@ -5,8 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; +import static org.mockito.Mockito.*; import java.util.HashMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -79,6 +78,7 @@ void setEvaluationContextShouldAllowChaining() { assertEquals(client, result); } + @Test @DisplayName("Should not call evaluation methods when the provider has state FATAL") void shouldNotCallEvaluationMethodsWhenProviderIsInFatalErrorState() { diff --git a/src/test/java/dev/openfeature/sdk/TrackingSpecTest.java b/src/test/java/dev/openfeature/sdk/TrackingSpecTest.java new file mode 100644 index 000000000..ebd62a938 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/TrackingSpecTest.java @@ -0,0 +1,158 @@ +package dev.openfeature.sdk; + +import com.google.common.collect.Maps; +import dev.openfeature.sdk.fixtures.ProviderFixture; +import dev.openfeature.sdk.testutils.FeatureProviderTestUtils; +import lombok.SneakyThrows; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.*; + +class TrackingSpecTest { + + private OpenFeatureAPI api; + private Client client; + + @BeforeEach + void getApiInstance() { + api = OpenFeatureAPI.getInstance(); + client = api.getClient(); + } + + + @Specification(number = "6.1.1.1", text = "The `client` MUST define a function for tracking the occurrence of " + + "a particular action or application state, with parameters `tracking event name` (string, required), " + + "`evaluation context` (optional) and `tracking event details` (optional), which returns nothing.") + @Specification(number = "6.1.2.1", text = "The `client` MUST define a function for tracking the occurrence of a " + + "particular action or application state, with parameters `tracking event name` (string, required) and " + + "`tracking event details` (optional), which returns nothing.") + @Test + @SneakyThrows + void trackMethodFulfillsSpec() { + + ImmutableContext ctx = new ImmutableContext(); + MutableTrackingEventDetails details = new MutableTrackingEventDetails(0.0f); + assertThatCode(() -> client.track("event")).doesNotThrowAnyException(); + assertThatCode(() -> client.track("event", ctx)).doesNotThrowAnyException(); + assertThatCode(() -> client.track("event", details)).doesNotThrowAnyException(); + assertThatCode(() -> client.track("event", ctx, details)).doesNotThrowAnyException(); + + client.track("event", details); + client.track("event", ctx, details); + + + assertThrows(NullPointerException.class, () -> client.track(null, ctx, details)); + assertThrows(NullPointerException.class, () -> client.track("event", null, details)); + assertThrows(NullPointerException.class, () -> client.track("event", ctx, null)); + assertThrows(NullPointerException.class, () -> client.track(null, null, null)); + assertThrows(NullPointerException.class, () -> client.track(null, ctx)); + assertThrows(NullPointerException.class, () -> client.track(null, details)); + assertThrows(NullPointerException.class, () -> client.track("event", (EvaluationContext) null)); + assertThrows(NullPointerException.class, () -> client.track("event", (TrackingEventDetails) null)); + + assertThrows(IllegalArgumentException.class, () -> client.track("")); + assertThrows(IllegalArgumentException.class, () -> client.track("", ctx)); + assertThrows(IllegalArgumentException.class, () -> client.track("", ctx, details)); + + + Class clientClass = OpenFeatureClient.class; + assertEquals( + void.class, + clientClass.getMethod("track", String.class).getReturnType(), + "The method should return void."); + assertEquals( + void.class, + clientClass.getMethod("track", String.class, EvaluationContext.class).getReturnType(), + "The method should return void."); + + assertEquals( + void.class, + clientClass.getMethod("track", String.class, EvaluationContext.class, TrackingEventDetails.class).getReturnType(), + "The method should return void."); + + + } + + @Specification(number = "6.1.3", text = "The evaluation context passed to the provider's track function " + + "MUST be merged in the order: API (global; lowest precedence) -> transaction -> client -> " + + "invocation (highest precedence), with duplicate values being overwritten.") + @Test + void contextsGetMerged() { + + api.setTransactionContextPropagator(new ThreadLocalTransactionContextPropagator()); + + Map apiAttr = new HashMap<>(); + apiAttr.put("my-key", new Value("hey")); + apiAttr.put("my-api-key", new Value("333")); + EvaluationContext apiCtx = new ImmutableContext(apiAttr); + api.setEvaluationContext(apiCtx); + + Map txAttr = new HashMap<>(); + txAttr.put("my-key", new Value("overwritten")); + txAttr.put("my-tx-key", new Value("444")); + EvaluationContext txCtx = new ImmutableContext(txAttr); + api.setTransactionContext(txCtx); + + Map clAttr = new HashMap<>(); + clAttr.put("my-key", new Value("overwritten-again")); + clAttr.put("my-cl-key", new Value("555")); + EvaluationContext clCtx = new ImmutableContext(clAttr); + client.setEvaluationContext(clCtx); + + FeatureProvider provider = ProviderFixture.createMockedProvider(); + FeatureProviderTestUtils.setFeatureProvider(provider); + + client.track("event", new MutableContext().add("my-key", "final"), new MutableTrackingEventDetails(0.0f)); + + Map expectedMap = Maps.newHashMap(); + expectedMap.put("my-key", new Value("final")); + expectedMap.put("my-api-key", new Value("333")); + expectedMap.put("my-tx-key", new Value("444")); + expectedMap.put("my-cl-key", new Value("555")); + verify(provider).track(eq("event"), argThat(ctx -> ctx.asMap().equals(expectedMap)), notNull()); + } + + @Specification(number = "6.1.4", text = "If the client's `track` function is called and the associated provider " + + "does not implement tracking, the client's `track` function MUST no-op.") + @Test + void noopProvider() { + FeatureProvider provider = spy(FeatureProvider.class); + api.setProvider(provider); + client.track("event"); + verify(provider).track(any(), any(), any()); + } + + @Specification(number = "6.2.1", text = "The `tracking event details` structure MUST define an optional numeric " + + "`value`, associating a scalar quality with an `tracking event`.") + @Specification(number = "6.2.2", text = "The `tracking event details` MUST support the inclusion of custom " + + "fields, having keys of type `string`, and values of type `boolean | string | number | structure`.") + @Test + void eventDetails() { + assertEquals(0f, new MutableTrackingEventDetails().getTarget()); + assertEquals(9.87f, new MutableTrackingEventDetails(9.87f).getTarget()); + Map expectedMap = Maps.newHashMap(); + expectedMap.put("my-str", new Value("str")); + expectedMap.put("my-num", new Value(1)); + expectedMap.put("my-bool", new Value(true)); + expectedMap.put("my-struct", new Value(new MutableTrackingEventDetails())); + + + MutableStructure details = new MutableTrackingEventDetails() + .add("my-str", new Value("str")) + .add("my-num", new Value(1)) + .add("my-bool", new Value(true)) + .add("my-struct", new Value(new MutableTrackingEventDetails())); + + assertEquals(expectedMap, details.asMap()); + } + +}