diff --git a/build.gradle b/build.gradle index 424ee0e1..bb6e60d7 100644 --- a/build.gradle +++ b/build.gradle @@ -84,7 +84,7 @@ sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 def wasmResourcePath = "$projectDir/src/main/resources" -def wasmVersion = "1.25.3" +def wasmVersion = "1.31.2" def wasmUrl = "https://unpkg.com/@devcycle/bucketing-assembly-script@$wasmVersion/build/bucketing-lib.release.wasm" task downloadDVCBucketingWASM(type: Download) { src wasmUrl @@ -113,7 +113,7 @@ ext { junit_version = "4.13.2" mockito_core_version = "5.6.0" protobuf_version = "3.24.4" - openfeature_version = "1.7.0" + openfeature_version = "1.14.1" eventsource_version = "4.1.1" } diff --git a/src/examples/java/com/devcycle/examples/LocalExample.java b/src/examples/java/com/devcycle/examples/LocalExample.java index ccb09532..2df9724c 100644 --- a/src/examples/java/com/devcycle/examples/LocalExample.java +++ b/src/examples/java/com/devcycle/examples/LocalExample.java @@ -1,6 +1,7 @@ package com.devcycle.examples; import com.devcycle.sdk.server.common.logging.SimpleDevCycleLogger; +import com.devcycle.sdk.server.common.model.DevCycleEvent; import com.devcycle.sdk.server.common.model.DevCycleUser; import com.devcycle.sdk.server.local.api.DevCycleLocalClient; import com.devcycle.sdk.server.local.model.DevCycleLocalOptions; @@ -24,9 +25,7 @@ public static void main(String[] args) throws InterruptedException { Boolean defaultValue = false; DevCycleLocalOptions options = DevCycleLocalOptions.builder() - .configPollingIntervalMS(60000) .customLogger(new SimpleDevCycleLogger(SimpleDevCycleLogger.Level.DEBUG)) - .enableBetaRealtimeUpdates(true) .build(); // Initialize DevCycle Client @@ -50,6 +49,10 @@ public static void main(String[] args) throws InterruptedException { } else { System.out.println("feature is NOT enabled"); } - Thread.sleep(10000); + + DevCycleEvent event = DevCycleEvent.builder().type("local-test").build(); + client.track(user, event); + + Thread.sleep(20000); } } diff --git a/src/examples/java/com/devcycle/examples/OpenFeatureExample.java b/src/examples/java/com/devcycle/examples/OpenFeatureExample.java index ceb23d0f..6e8652c0 100644 --- a/src/examples/java/com/devcycle/examples/OpenFeatureExample.java +++ b/src/examples/java/com/devcycle/examples/OpenFeatureExample.java @@ -1,5 +1,6 @@ package com.devcycle.examples; +import com.devcycle.sdk.server.common.logging.SimpleDevCycleLogger; import com.devcycle.sdk.server.local.api.DevCycleLocalClient; import com.devcycle.sdk.server.local.model.DevCycleLocalOptions; import dev.openfeature.sdk.*; @@ -15,22 +16,16 @@ public static void main(String[] args) throws InterruptedException { System.exit(1); } - DevCycleLocalOptions options = DevCycleLocalOptions.builder().configPollingIntervalMS(60000) - .disableAutomaticEventLogging(false).disableCustomEventLogging(false).build(); + DevCycleLocalOptions options = DevCycleLocalOptions.builder() + .customLogger(new SimpleDevCycleLogger(SimpleDevCycleLogger.Level.DEBUG)) + .build(); // Initialize DevCycle Client DevCycleLocalClient devCycleClient = new DevCycleLocalClient(server_sdk_key, options); - for (int i = 0; i < 10; i++) { - if (devCycleClient.isInitialized()) { - break; - } - Thread.sleep(500); - } - // Setup OpenFeature with the DevCycle Provider OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProvider(devCycleClient.getOpenFeatureProvider()); + api.setProviderAndWait(devCycleClient.getOpenFeatureProvider()); Client openFeatureClient = api.getClient(); @@ -41,7 +36,7 @@ public static void main(String[] args) throws InterruptedException { context.add("language", "en"); context.add("country", "CA"); context.add("appVersion", "1.0.0"); - context.add("appBuild", "1"); + context.add("appBuild", 1.0); context.add("deviceModel", "Macbook"); // Add Devcycle Custom Data values @@ -81,5 +76,14 @@ public static void main(String[] args) throws InterruptedException { System.out.println("Value: " + details.getValue()); System.out.println("Reason: " + details.getReason()); + MutableTrackingEventDetails eventDetails = new MutableTrackingEventDetails(610.1); + eventDetails.add("test-string", "test-value"); + eventDetails.add("test-number", 123.456); + eventDetails.add("test-boolean", true); + eventDetails.add("test-json", new Value(Structure.mapToStructure(defaultJsonData))); + + openFeatureClient.track("test-of-event", context, eventDetails); + + Thread.sleep(20000); } } diff --git a/src/main/java/com/devcycle/sdk/server/common/api/IDevCycleClient.java b/src/main/java/com/devcycle/sdk/server/common/api/IDevCycleClient.java index 9ccc241c..14955fbb 100644 --- a/src/main/java/com/devcycle/sdk/server/common/api/IDevCycleClient.java +++ b/src/main/java/com/devcycle/sdk/server/common/api/IDevCycleClient.java @@ -1,5 +1,7 @@ package com.devcycle.sdk.server.common.api; +import com.devcycle.sdk.server.common.exception.DevCycleException; +import com.devcycle.sdk.server.common.model.DevCycleEvent; import com.devcycle.sdk.server.common.model.DevCycleUser; import com.devcycle.sdk.server.common.model.Variable; import dev.openfeature.sdk.FeatureProvider; @@ -32,6 +34,8 @@ public interface IDevCycleClient { */ Variable variable(DevCycleUser user, String key, T defaultValue); + void track(DevCycleUser user, DevCycleEvent event) throws DevCycleException; + /** * Close the client and release any resources. */ diff --git a/src/main/java/com/devcycle/sdk/server/common/model/DevCycleUser.java b/src/main/java/com/devcycle/sdk/server/common/model/DevCycleUser.java index bfd200e4..955417a4 100755 --- a/src/main/java/com/devcycle/sdk/server/common/model/DevCycleUser.java +++ b/src/main/java/com/devcycle/sdk/server/common/model/DevCycleUser.java @@ -104,6 +104,11 @@ public class DevCycleUser { @JsonProperty("sdkVersion") private String sdkVersion = getPlatformData().getSdkVersion(); + @Schema(description = "DevCycle SDK Platform") + @Builder.Default + @JsonProperty("sdkPlatform") + private String sdkPlatform = null; + @Schema(description = "Hostname where the SDK is running") @Builder.Default @JsonProperty("hostname") @@ -149,13 +154,13 @@ public static DevCycleUser fromEvaluationContext(EvaluationContext ctx) { throw new TargetingKeyMissingError(); } - DevCycleUser user = DevCycleUser.builder().userId(userId).build(); + DevCycleUser user = DevCycleUser.builder().userId(userId).sdkPlatform("java-of").build(); Map customData = new LinkedHashMap<>(); Map privateCustomData = new LinkedHashMap<>(); for (String key : ctx.keySet()) { - if (key.equals("user_id")) { + if (key.equals("user_id") || key.equals("targetingKey")) { continue; } diff --git a/src/main/java/com/devcycle/sdk/server/common/model/PlatformData.java b/src/main/java/com/devcycle/sdk/server/common/model/PlatformData.java index 7c3722b9..873cceb8 100644 --- a/src/main/java/com/devcycle/sdk/server/common/model/PlatformData.java +++ b/src/main/java/com/devcycle/sdk/server/common/model/PlatformData.java @@ -20,23 +20,31 @@ public class PlatformData { @Schema(description = "Platform the SDK is running on") @Builder.Default private String platform = "Java"; + @Schema(description = "Version of the platform the SDK is running on") @Builder.Default private String platformVersion = System.getProperty("java.version"); + @Schema(description = "DevCycle SDK type") @Builder.Default private PlatformData.SdkTypeEnum sdkType = PlatformData.SdkTypeEnum.SERVER; + @Schema(description = "DevCycle SDK Version") @Builder.Default private String sdkVersion = "2.5.0"; + + @Schema(description = "DevCycle SDK Platform") + private String sdkPlatform = null; + @Schema(description = "Hostname where the SDK is running") private String hostname; - public PlatformData(String platform, String platformVersion, SdkTypeEnum sdkType, String sdkVersion, String hostname) { + public PlatformData(String platform, String platformVersion, SdkTypeEnum sdkType, String sdkVersion, String sdkPlatform, String hostname) { this.platform = platform; this.platformVersion = platformVersion; this.sdkType = sdkType; this.sdkVersion = sdkVersion; + this.sdkPlatform = sdkPlatform; try { this.hostname = hostname != null ? hostname : InetAddress.getLocalHost().getHostName(); } catch (UnknownHostException e) { @@ -53,6 +61,9 @@ public String toString() { platformData.put("platformVersion", platformVersion); platformData.put("sdkType", sdkType.toString()); platformData.put("sdkVersion", sdkVersion); + if (sdkPlatform != null) { + platformData.put("sdkPlatform", sdkPlatform); + } platformData.put("hostname", hostname); String platformDataString = null; diff --git a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java index 7900025b..6de89d8d 100755 --- a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java +++ b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java @@ -25,7 +25,6 @@ public final class DevCycleLocalClient implements IDevCycleClient { private final String sdkKey; - private final DevCycleProvider openFeatureProvider; private final LocalBucketing localBucketing = new LocalBucketing(); private final EnvironmentConfigManager configManager; private EventQueueManager eventQueueManager; @@ -61,7 +60,6 @@ public DevCycleLocalClient(String sdkKey, DevCycleLocalOptions dvcOptions) { } catch (Exception e) { DevCycleLogger.error("Error creating event queue due to error: " + e.getMessage()); } - this.openFeatureProvider = new DevCycleProvider(this); } /** @@ -249,12 +247,24 @@ public void close() { } } + + private static DevCycleProvider openFeatureProvider = null; + /** * @return the OpenFeature provider for this client. */ @Override public FeatureProvider getOpenFeatureProvider() { - return this.openFeatureProvider; + if (openFeatureProvider == null) { + synchronized (DevCycleLocalClient.class) { + if (openFeatureProvider == null) { + openFeatureProvider = new DevCycleProvider(this); + } + PlatformData platformData = PlatformData.builder().sdkPlatform("java-of").build(); + localBucketing.setPlatformData(platformData.toString()); + } + } + return openFeatureProvider; } @Override diff --git a/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java b/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java index 3590d7a7..bae42ea0 100644 --- a/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java +++ b/src/main/java/com/devcycle/sdk/server/openfeature/DevCycleProvider.java @@ -1,13 +1,18 @@ package com.devcycle.sdk.server.openfeature; import com.devcycle.sdk.server.common.api.IDevCycleClient; +import com.devcycle.sdk.server.common.exception.DevCycleException; +import com.devcycle.sdk.server.common.model.DevCycleEvent; import com.devcycle.sdk.server.common.model.DevCycleUser; import com.devcycle.sdk.server.common.model.Variable; import dev.openfeature.sdk.*; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import dev.openfeature.sdk.exceptions.GeneralError; import dev.openfeature.sdk.exceptions.TypeMismatchError; +import java.math.BigDecimal; import java.util.Map; +import java.util.Optional; public class DevCycleProvider implements FeatureProvider { private static final String PROVIDER_NAME = "DevCycle"; @@ -23,6 +28,28 @@ public Metadata getMetadata() { return () -> PROVIDER_NAME + " " + devcycleClient.getSDKPlatform(); } + @Override + public void initialize(EvaluationContext evaluationContext) throws Exception { + if (devcycleClient.isInitialized()) { + return; + } + + long deadline = 2 * 1000; // Delay in milliseconds + long start = System.currentTimeMillis(); + + do { + if (deadline <= System.currentTimeMillis() - start) { + throw new GeneralError("DevCycle client not initialized within 2 seconds"); + } + Thread.sleep(5); + } while (!devcycleClient.isInitialized()); + } + + @Override + public void shutdown() { + devcycleClient.close(); + } + @Override public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { return resolvePrimitiveVariable(key, defaultValue, ctx); @@ -62,83 +89,121 @@ public ProviderEvaluation getObjectEvaluation(String key, Value defaultVa } } - if (devcycleClient.isInitialized()) { - try { - DevCycleUser user = DevCycleUser.fromEvaluationContext(ctx); + if (!devcycleClient.isInitialized()) { + throw new ProviderNotReadyError("DevCycle client not initialized"); + } + + try { + DevCycleUser user = DevCycleUser.fromEvaluationContext(ctx); - Variable variable = devcycleClient.variable(user, key, defaultValue.asStructure().asObjectMap()); + Variable variable = devcycleClient.variable(user, key, defaultValue.asStructure().asObjectMap()); - if (variable == null || variable.getIsDefaulted()) { + if (variable == null || variable.getIsDefaulted()) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.toString()) + .build(); + } else { + if (variable.getValue() instanceof Map) { + // JSON objects are managed as Map implementations and must be converted to an OpenFeature structure + Value objectValue = new Value(Structure.mapToStructure((Map) variable.getValue())); return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DEFAULT.toString()) + .value(objectValue) + .reason(Reason.TARGETING_MATCH.toString()) .build(); } else { - if (variable.getValue() instanceof Map) { - // JSON objects are managed as Map implementations and must be converted to an OpenFeature structure - Value objectValue = new Value(Structure.mapToStructure((Map) variable.getValue())); - return ProviderEvaluation.builder() - .value(objectValue) - .reason(Reason.TARGETING_MATCH.toString()) - .build(); - } else { - throw new TypeMismatchError("DevCycle variable for key " + key + " is not a JSON object"); - } + throw new TypeMismatchError("DevCycle variable for key " + key + " is not a JSON object"); } - } catch (IllegalArgumentException e) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.ERROR.toString()) - .errorCode(ErrorCode.GENERAL) - .errorMessage(e.getMessage()) - .build(); } - } else { - throw new ProviderNotReadyError("DevCycle client not initialized"); + } catch (IllegalArgumentException e) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.toString()) + .errorCode(ErrorCode.GENERAL) + .errorMessage(e.getMessage()) + .build(); } } - @Override - public void shutdown() { - devcycleClient.close(); - } - ProviderEvaluation resolvePrimitiveVariable(String key, T defaultValue, EvaluationContext ctx) { - if (devcycleClient.isInitialized()) { - try { - DevCycleUser user = DevCycleUser.fromEvaluationContext(ctx); + if (!devcycleClient.isInitialized()) { + throw new ProviderNotReadyError("DevCycle client not initialized"); + } - Variable variable = devcycleClient.variable(user, key, defaultValue); + try { + DevCycleUser user = DevCycleUser.fromEvaluationContext(ctx); - if (variable == null || variable.getIsDefaulted()) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DEFAULT.toString()) - .build(); - } else { - T value = variable.getValue(); - if (variable.getType() == Variable.TypeEnum.NUMBER && defaultValue.getClass() == Integer.class) { - // Internally in the DevCycle SDK all number values are stored as Doubles - // need to explicitly convert to an Integer if the requested type is Integer - Number numVal = (Number) value; - value = (T) Integer.valueOf(numVal.intValue()); - } - - return ProviderEvaluation.builder() - .value(value) - .reason(Reason.TARGETING_MATCH.toString()) - .build(); - } - } catch (IllegalArgumentException e) { + Variable variable = devcycleClient.variable(user, key, defaultValue); + + if (variable == null || variable.getIsDefaulted()) { return ProviderEvaluation.builder() .value(defaultValue) - .reason(Reason.ERROR.toString()) - .errorCode(ErrorCode.GENERAL) - .errorMessage(e.getMessage()) + .reason(Reason.DEFAULT.toString()) + .build(); + } else { + T value = variable.getValue(); + if (variable.getType() == Variable.TypeEnum.NUMBER && defaultValue.getClass() == Integer.class) { + // Internally in the DevCycle SDK all number values are stored as Doubles + // need to explicitly convert to an Integer if the requested type is Integer + Number numVal = (Number) value; + value = (T) Integer.valueOf(numVal.intValue()); + } + + return ProviderEvaluation.builder() + .value(value) + .reason(Reason.TARGETING_MATCH.toString()) .build(); } - } else { + } catch (IllegalArgumentException e) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.toString()) + .errorCode(ErrorCode.GENERAL) + .errorMessage(e.getMessage()) + .build(); + } + } + + @Override + public void track(String eventName, EvaluationContext context, TrackingEventDetails details) { + if (!devcycleClient.isInitialized()) { throw new ProviderNotReadyError("DevCycle client not initialized"); } + + DevCycleUser user = DevCycleUser.fromEvaluationContext(context); + try { + BigDecimal eventValue = extractEventValue(details); + Map metaData = getMetadataWithoutValue(details); + + DevCycleEvent event = DevCycleEvent.builder() + .type(eventName) + .value(eventValue) + .metaData(metaData) + .build(); + devcycleClient.track(user, event); + } catch (DevCycleException e) { + throw new GeneralError(e); + } + } + + private BigDecimal extractEventValue(TrackingEventDetails details) { + Optional rawValue = details.getValue(); + if (rawValue.isEmpty()) { + return null; + } + + Number numberValue = rawValue.get(); + if (numberValue == null) { + return null; + } + + Value value = Value.objectToValue(numberValue); + return value.isNumber() ? new BigDecimal(Double.toString(value.asDouble())) : null; + } + + private Map getMetadataWithoutValue(TrackingEventDetails details) { + Map metaData = details.asObjectMap(); + metaData.remove("value"); + return metaData; } } diff --git a/src/main/resources/bucketing-lib.release.wasm b/src/main/resources/bucketing-lib.release.wasm index 348b8bd0..5eb145f5 100644 Binary files a/src/main/resources/bucketing-lib.release.wasm and b/src/main/resources/bucketing-lib.release.wasm differ diff --git a/src/test/java/com/devcycle/sdk/server/helpers/MockDevCycleClient.java b/src/test/java/com/devcycle/sdk/server/helpers/MockDevCycleClient.java index 3dc7045c..5b4a0cbf 100644 --- a/src/test/java/com/devcycle/sdk/server/helpers/MockDevCycleClient.java +++ b/src/test/java/com/devcycle/sdk/server/helpers/MockDevCycleClient.java @@ -1,6 +1,8 @@ package com.devcycle.sdk.server.helpers; import com.devcycle.sdk.server.common.api.IDevCycleClient; +import com.devcycle.sdk.server.common.exception.DevCycleException; +import com.devcycle.sdk.server.common.model.DevCycleEvent; import com.devcycle.sdk.server.common.model.DevCycleUser; import com.devcycle.sdk.server.common.model.Variable; import com.devcycle.sdk.server.openfeature.DevCycleProvider; @@ -26,6 +28,9 @@ public void close() { } + @Override + public void track(DevCycleUser user, DevCycleEvent event) throws DevCycleException { return; } + @Override public DevCycleProvider getOpenFeatureProvider() { return null; diff --git a/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java b/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java index 95952c61..fce62bb5 100644 --- a/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java +++ b/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java @@ -2,10 +2,7 @@ import com.devcycle.sdk.server.common.api.IRestOptions; import com.devcycle.sdk.server.common.logging.IDevCycleLogger; -import com.devcycle.sdk.server.common.model.BaseVariable; -import com.devcycle.sdk.server.common.model.DevCycleUser; -import com.devcycle.sdk.server.common.model.Feature; -import com.devcycle.sdk.server.common.model.Variable; +import com.devcycle.sdk.server.common.model.*; import com.devcycle.sdk.server.helpers.LocalConfigServer; import com.devcycle.sdk.server.helpers.TestDataFixtures; import com.devcycle.sdk.server.local.api.DevCycleLocalClient; @@ -20,6 +17,7 @@ import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; +import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -318,6 +316,18 @@ public void allVariablesTest() { Assert.assertEquals(variables.size(), 4); } + @Test + public void trackTest() { + DevCycleUser user = getUser(); + DevCycleEvent event = DevCycleEvent.builder() + .type("test-event") + .value(BigDecimal.valueOf(123.45)) + .metaData(Map.of("test-key", "test-value")) + .build(); + + client.track(user, event); + } + @Test public void setClientCustomDataWithBadMap() { // should be a no-op diff --git a/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderLocalSDKTest.java b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderLocalSDKTest.java index 8cf14eca..23672e94 100644 --- a/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderLocalSDKTest.java +++ b/src/test/java/com/devcycle/sdk/server/openfeature/DevCycleProviderLocalSDKTest.java @@ -150,5 +150,15 @@ public void testGetStringEvaluation() { Assert.assertEquals(result.getReason(), Reason.TARGETING_MATCH.toString()); } + @Test + public void testTrackEvent() { + EvaluationContext ctx = getContext(); + MutableTrackingEventDetails eventDetails = new MutableTrackingEventDetails(123.456); + eventDetails.add("test-key", "test-value"); + client.getOpenFeatureProvider().track("test-event", ctx, eventDetails); + Number value = eventDetails.getValue().orElse(null); + Assert.assertEquals(123.456, value.doubleValue(), 0.0001); + Assert.assertEquals("test-value", eventDetails.getValue("test-key").asString()); + } }