diff --git a/README.md b/README.md index c710de664..a277600df 100644 --- a/README.md +++ b/README.md @@ -120,17 +120,18 @@ See [here](https://javadoc.io/doc/dev.openfeature/sdk/latest/) for the Javadocs. ## 🌟 Features -| Status | Features | Description | -| ------ |-----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| -| ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. | -| ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). | -| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | -| ✅ | [Logging](#logging) | Integrate with popular logging packages. | -| ✅ | [Domains](#domains) | Logically bind clients with providers. | -| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | -| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | -| ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread). | -| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | +| Status | Features | Description | +| ------ |---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. | +| ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). | +| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | +| ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations. | +| ✅ | [Logging](#logging) | Integrate with popular logging packages. | +| ✅ | [Domains](#domains) | Logically bind clients with providers. | +| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | +| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | +| ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread). | +| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌ @@ -215,6 +216,16 @@ Once you've added a hook as a dependency, it can be registered at the global, cl FlagEvaluationOptions.builder().hook(new ExampleHook()).build()); ``` +### Tracking + +The [tracking API](https://openfeature.dev/specification/sections/tracking/) allows you to use OpenFeature abstractions to associate user actions with feature flag evaluations. +This is essential for robust experimentation powered by feature flags. Note that, unlike methods that handle feature flag evaluations, calling `track(...)` may throw an `IllegalArgumentException` if an empty string is passed as the `trackingEventName`. + +```java +OpenFeatureAPI api = OpenFeatureAPI.getInstance(); +api.getClient().track("visited-promo-page", new MutableTrackingEventDetails(99.77).add("currency", "USD")); +``` + ### Logging The Java SDK uses SLF4J. See the [SLF4J manual](https://slf4j.org/manual.html) for complete documentation. 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..706818e85 100644 --- a/src/main/java/dev/openfeature/sdk/FeatureProvider.java +++ b/src/main/java/dev/openfeature/sdk/FeatureProvider.java @@ -71,4 +71,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, EvaluationContext context, TrackingEventDetails details) { + + } } diff --git a/src/main/java/dev/openfeature/sdk/ImmutableTrackingEventDetails.java b/src/main/java/dev/openfeature/sdk/ImmutableTrackingEventDetails.java new file mode 100644 index 000000000..b535bb7da --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/ImmutableTrackingEventDetails.java @@ -0,0 +1,53 @@ +package dev.openfeature.sdk; + +import dev.openfeature.sdk.internal.ExcludeFromGeneratedCoverageReport; +import lombok.experimental.Delegate; + +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + + +/** + * ImmutableTrackingEventDetails represents data pertinent to a particular tracking event. + */ +public class ImmutableTrackingEventDetails implements TrackingEventDetails { + + @Delegate(excludes = DelegateExclusions.class) + private final ImmutableStructure structure; + + private final Number value; + + public ImmutableTrackingEventDetails() { + this.value = null; + this.structure = new ImmutableStructure(); + } + + public ImmutableTrackingEventDetails(final Number value) { + this.value = value; + this.structure = new ImmutableStructure(); + } + + public ImmutableTrackingEventDetails(final Number value, final Map attributes) { + this.value = value; + this.structure = new ImmutableStructure(attributes); + } + + /** + * Returns the optional tracking value. + */ + public Optional getValue() { + return Optional.ofNullable(value); + } + + + @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/MutableTrackingEventDetails.java b/src/main/java/dev/openfeature/sdk/MutableTrackingEventDetails.java new file mode 100644 index 000000000..9f0de8c3a --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/MutableTrackingEventDetails.java @@ -0,0 +1,94 @@ +package dev.openfeature.sdk; + +import dev.openfeature.sdk.internal.ExcludeFromGeneratedCoverageReport; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import lombok.experimental.Delegate; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +/** + * MutableTrackingEventDetails represents data pertinent to a particular tracking event. + */ +@EqualsAndHashCode +@ToString +public class MutableTrackingEventDetails implements TrackingEventDetails { + + private final Number value; + @Delegate(excludes = MutableTrackingEventDetails.DelegateExclusions.class) + private final MutableStructure structure; + + public MutableTrackingEventDetails() { + this.value = null; + this.structure = new MutableStructure(); + } + + public MutableTrackingEventDetails(final Number value) { + this.value = value; + this.structure = new MutableStructure(); + } + + /** + * Returns the optional tracking value. + */ + public Optional getValue() { + return Optional.ofNullable(value); + } + + // override @Delegate methods so that we can use "add" methods and still return MutableTrackingEventDetails, + // not Structure + public MutableTrackingEventDetails add(String key, Boolean value) { + this.structure.add(key, value); + return this; + } + + public MutableTrackingEventDetails add(String key, String value) { + this.structure.add(key, value); + return this; + } + + public MutableTrackingEventDetails add(String key, Integer value) { + this.structure.add(key, value); + return this; + } + + public MutableTrackingEventDetails add(String key, Double value) { + this.structure.add(key, value); + return this; + } + + public MutableTrackingEventDetails add(String key, Instant value) { + this.structure.add(key, value); + return this; + } + + public MutableTrackingEventDetails add(String key, Structure value) { + this.structure.add(key, value); + return this; + } + + public MutableTrackingEventDetails add(String key, List value) { + this.structure.add(key, value); + return this; + } + + public MutableTrackingEventDetails add(String key, Value value) { + this.structure.add(key, value); + return this; + } + + + @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..ea566e652 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,15 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; +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 +29,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 +68,56 @@ public OpenFeatureClient( this.hookSupport = new HookSupport(); } + /** + * {@inheritDoc} + */ @Override public ProviderState getProviderState() { return openfeatureApi.getFeatureProviderStateManager(domain).getState(); } + /** + * {@inheritDoc} + */ + @Override + public void track(String trackingEventName) { + validateTrackingEventName(trackingEventName); + invokeTrack(trackingEventName, null, null); + } + + + /** + * {@inheritDoc} + */ + @Override + public void track(String trackingEventName, EvaluationContext context) { + validateTrackingEventName(trackingEventName); + Objects.requireNonNull(context); + invokeTrack(trackingEventName, context, null); + } + + /** + * {@inheritDoc} + */ + @Override + public void track(String trackingEventName, TrackingEventDetails details) { + validateTrackingEventName(trackingEventName); + Objects.requireNonNull(details); + invokeTrack(trackingEventName, null, details); + } + + /** + * {@inheritDoc} + */ + @Override + public void track(String trackingEventName, EvaluationContext context, TrackingEventDetails details) { + validateTrackingEventName(trackingEventName); + Objects.requireNonNull(context); + Objects.requireNonNull(details); + invokeTrack(trackingEventName, mergeEvaluationContext(context), details); + } + + /** * {@inheritDoc} */ @@ -115,7 +161,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 +229,19 @@ private static void enrichDetailsWithErrorDefaults(T defaultValue, FlagEvalu details.setReason(Reason.ERROR.toString()); } + private static void validateTrackingEventName(String str) { + Objects.requireNonNull(str); + if (str.isEmpty()) { + throw new IllegalArgumentException("trackingEventName cannot be empty"); + } + } + + private void invokeTrack(String trackingEventName, EvaluationContext context, TrackingEventDetails details) { + 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 +303,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 +319,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 +335,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 +351,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 +367,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 +383,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 +399,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 +415,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 +431,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 +442,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..ec9c8a8fe --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/Tracking.java @@ -0,0 +1,42 @@ +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 + * @throws IllegalArgumentException if {@code trackingEventName} is null + */ + 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 + * @throws IllegalArgumentException if {@code trackingEventName} is null + */ + 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 + * @throws IllegalArgumentException if {@code trackingEventName} is null + */ + 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 + * @throws IllegalArgumentException if {@code trackingEventName} is null + */ + 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/MutableTrackingEventDetailsTest.java b/src/test/java/dev/openfeature/sdk/MutableTrackingEventDetailsTest.java new file mode 100644 index 000000000..a169107f1 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/MutableTrackingEventDetailsTest.java @@ -0,0 +1,51 @@ +package dev.openfeature.sdk; + +import com.google.common.collect.Lists; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class MutableTrackingEventDetailsTest { + + + @Test + void hasDefaultValue() { + MutableTrackingEventDetails track = new MutableTrackingEventDetails(); + assertFalse(track.getValue().isPresent()); + } + + @Test + void shouldUseCorrectValue() { + MutableTrackingEventDetails track = new MutableTrackingEventDetails(3); + assertThat(track.getValue()).hasValue(3); + } + + @Test + void shouldStoreAttributes() { + MutableTrackingEventDetails track = new MutableTrackingEventDetails(); + track.add("key0", true); + track.add("key1", 1); + track.add("key2", "2"); + track.add("key3", 1d); + track.add("key4", 4); + track.add("key5", Instant.parse("2023-12-03T10:15:30Z")); + track.add("key6", new MutableContext()); + track.add("key7", new Value(7)); + track.add("key8", Lists.newArrayList(new Value(8), new Value(9))); + + assertEquals(new Value(true), track.getValue("key0")); + assertEquals(new Value(1), track.getValue("key1")); + assertEquals(new Value("2"), track.getValue("key2")); + assertEquals(new Value(1d), track.getValue("key3")); + assertEquals(new Value(4), track.getValue("key4")); + assertEquals(new Value(Instant.parse("2023-12-03T10:15:30Z")), track.getValue("key5")); + assertEquals(new Value(new MutableContext()), track.getValue("key6")); + assertEquals(new Value(7), track.getValue("key7")); + assertArrayEquals(new Object[]{new Value(8), new Value(9)}, track.getValue("key8").asList().toArray()); + } +} \ No newline at end of file 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..6d195607b --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/TrackingSpecTest.java @@ -0,0 +1,189 @@ +package dev.openfeature.sdk; + +import com.google.common.collect.ImmutableMap; +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.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.any; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +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(); + + 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() { + assertFalse(new MutableTrackingEventDetails().getValue().isPresent()); + assertFalse(new ImmutableTrackingEventDetails().getValue().isPresent()); + assertThat(new ImmutableTrackingEventDetails(2).getValue()).hasValue(2); + assertThat(new MutableTrackingEventDetails(9.87f).getValue()).hasValue(9.87f); + + + // using mutable tracking event details + 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())); + + MutableTrackingEventDetails 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()); + assertThatCode(() -> OpenFeatureAPI.getInstance().getClient(). + track( + "tracking-event-name", + new ImmutableContext(), + new MutableTrackingEventDetails())) + .doesNotThrowAnyException(); + + + // using immutable tracking event details + ImmutableMap expectedImmutable = ImmutableMap.of("my-str", new Value("str"), + "my-num", new Value(1), + "my-bool", new Value(true), + "my-struct", new Value(new ImmutableStructure()) + ); + + ImmutableTrackingEventDetails immutableDetails = new ImmutableTrackingEventDetails(2, expectedMap); + assertEquals(expectedImmutable, immutableDetails.asMap()); + assertThatCode(() -> OpenFeatureAPI.getInstance().getClient(). + track( + "tracking-event-name", + new ImmutableContext(), + new ImmutableTrackingEventDetails())) + .doesNotThrowAnyException(); + + + } + +}