diff --git a/manifests/java.yml b/manifests/java.yml index f8bf9193772..ccdfaf4d236 100644 --- a/manifests/java.yml +++ b/manifests/java.yml @@ -2113,7 +2113,7 @@ tests/: parametric/: test_feature_flag_exposure/: test_feature_flag_exposure.py: - Test_Feature_Flag_Exposure: missing_feature + Test_Feature_Flag_Exposure: v1.56.0 test_128_bit_traceids.py: Test_128_Bit_Traceids: v1.12.0 test_config_consistency.py: @@ -2150,7 +2150,7 @@ tests/: Test_Parametric_DDTrace_Baggage: incomplete_test_app (baggage endpoints are not implemented) Test_Parametric_DDTrace_Crash: bug (APMAPI-778) # The crash endpoint does not kill the application Test_Parametric_DDTrace_Current_Span: bug (APMAPI-778) # Fails to retreive the current span after a span has finished - Test_Parametric_FFE_Start: missing_feature + Test_Parametric_FFE_Start: v1.56.0 Test_Parametric_Otel_Baggage: missing_feature (otel baggage is not supported) Test_Parametric_Otel_Current_Span: bug (APMAPI-778) # Current span endpoint does not return DataDog spans created by the otel api Test_Parametric_Write_Log: missing_feature @@ -2331,9 +2331,15 @@ tests/: Test_Span_Links_From_Conflicting_Contexts: v1.43.0 Test_Span_Links_Omit_Tracestate_From_Conflicting_Contexts: missing_feature (implementation specs have not been determined) test_feature_flag_exposures.py: - Test_FFE_Exposure_Events: missing_feature - Test_FFE_Exposure_Events_Empty: missing_feature - Test_FFE_Exposure_Events_Errors: missing_feature + Test_FFE_Exposure_Events: + '*': irrelevant + spring-boot: v1.56.0 + Test_FFE_Exposure_Events_Empty: + '*': irrelevant + spring-boot: v1.56.0 + Test_FFE_Exposure_Events_Errors: + '*': irrelevant + spring-boot: v1.56.0 test_graphql.py: Test_GraphQLOperationErrorReporting: '*': missing_feature diff --git a/utils/build/docker/java/install_ddtrace.sh b/utils/build/docker/java/install_ddtrace.sh index 115af276c66..20d67c955ac 100755 --- a/utils/build/docker/java/install_ddtrace.sh +++ b/utils/build/docker/java/install_ddtrace.sh @@ -29,6 +29,9 @@ install_custom_jar() { # Look for custom dd-trace-api jar in custom binaries folder install_custom_jar "dd-trace-api*.jar" "dd-trace-api" "$MVN_OPTS" +# Look for custom dd-openfeature jar in custom binaries folder +install_custom_jar "dd-openfeature*.jar" "dd-openfeature" "$MVN_OPTS" + # Look for custom dd-trace-java jar in custom binaries folder if [ $(ls /binaries/dd-java-agent*.jar | wc -l) = 0 ]; then BUILD_URL="https://github.com/DataDog/dd-trace-java/releases/latest/download/dd-java-agent.jar" diff --git a/utils/build/docker/java/parametric/install_ddtrace.sh b/utils/build/docker/java/parametric/install_ddtrace.sh index c1bbf87695b..580bfc7c6e4 100755 --- a/utils/build/docker/java/parametric/install_ddtrace.sh +++ b/utils/build/docker/java/parametric/install_ddtrace.sh @@ -25,6 +25,9 @@ configure_custom_jar() { # Look for custom dd-trace-api jar in custom binaries folder configure_custom_jar "dd-trace-api*.jar" "dd-trace-api" "customDdTraceApi" +# Look for custom dd-openfeature jar in custom binaries folder +configure_custom_jar "dd-openfeature*.jar" "dd-openfeature" "customDdOpenfeature" + # Look for custom dd-java-agent jar in custom binaries folder CUSTOM_DD_JAVA_AGENT_COUNT=$(find /binaries/dd-java-agent*.jar 2>/dev/null | wc -l) if [ "$CUSTOM_DD_JAVA_AGENT_COUNT" = 0 ]; then diff --git a/utils/build/docker/java/parametric/pom.xml b/utils/build/docker/java/parametric/pom.xml index cd2b9f93084..1351080e7c4 100644 --- a/utils/build/docker/java/parametric/pom.xml +++ b/utils/build/docker/java/parametric/pom.xml @@ -24,6 +24,7 @@ 1.45.0 [1.47.0,) + [1.56.0,) @@ -69,6 +70,12 @@ opentelemetry-api ${opentelemetry.version} + + + com.datadoghq + dd-openfeature + ${dd-openfeature.version} + org.apache.commons commons-lang3 @@ -89,6 +96,11 @@ javax.mail 1.6.2 + + dev.openfeature + sdk + 1.18.2 + @@ -122,5 +134,22 @@ + + custom-dd-openfeature + + + customDdOpenfeature + + + + + com.datadoghq + dd-openfeature + dev + system + ${customDdOpenfeature} + + + diff --git a/utils/build/docker/java/parametric/src/main/java/com/datadoghq/trace/controller/FeatureFlagEvaluatorController.java b/utils/build/docker/java/parametric/src/main/java/com/datadoghq/trace/controller/FeatureFlagEvaluatorController.java new file mode 100644 index 00000000000..4fef048ef59 --- /dev/null +++ b/utils/build/docker/java/parametric/src/main/java/com/datadoghq/trace/controller/FeatureFlagEvaluatorController.java @@ -0,0 +1,186 @@ +package com.datadoghq.trace.controller; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.fasterxml.jackson.annotation.JsonAlias; +import datadog.trace.api.openfeature.Provider; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.NoOpProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/ffe") +public class FeatureFlagEvaluatorController { + + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagEvaluatorController.class); + + @Configuration + public static class FeatureFlagEvaluatorConfig { + + @Lazy + @Bean + public Client client() { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final String envProperty = System.getenv("DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"); + final FeatureProvider provider; + if (Boolean.parseBoolean(envProperty)) { + provider = new Provider(); + } else { + provider = new NoOpProvider() { + @Override + public ProviderState getState() { + return ProviderState.READY; + } + }; + } + api.setProviderAndWait(provider); + return api.getClient(); + } + } + + @Autowired + @Lazy + private Client client; + + @PostMapping(value = "/start") + public ResponseEntity start() { + final ProviderState state = client.getProviderState(); + if (state == ProviderState.READY) { + return ResponseEntity.ok(true); + } else { + return ResponseEntity.internalServerError().body(false); + } + } + + @PostMapping(value = "/evaluate", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) + public ResponseEntity> evaluate(@RequestBody final EvaluateRequest request) { + Object value; + String reason; + final EvaluationContext context = context(request); + try { + value = switch (request.getVariationType()) { + case "BOOLEAN" -> + client.getBooleanValue(request.getFlag(), (Boolean) request.getDefaultValue(), context); + case "STRING" -> client.getStringValue(request.getFlag(), (String) request.getDefaultValue(), context); + case "INTEGER" -> { + final Number integerEval = (Number) request.getDefaultValue(); + yield client.getIntegerValue(request.getFlag(), integerEval.intValue(), context); + } + case "NUMERIC" -> { + final Number doubleEval = (Number) request.getDefaultValue(); + yield client.getDoubleValue(request.getFlag(), doubleEval.doubleValue(), context); + } + case "JSON" -> { + final Value objectValue = client.getObjectValue(request.getFlag(), Value.objectToValue(request.getDefaultValue()), context); + yield context.convertValue(objectValue); + } + default -> request.getDefaultValue(); + }; + + reason = "DEFAULT"; + } catch (Throwable e) { + LOGGER.error("Error on resolution", e); + value = request.getDefaultValue(); + reason = "ERROR"; + } + final Map result = new HashMap<>(); + result.put("reason", reason); + result.put("value", value); + return ResponseEntity.ok(result); + } + + private static EvaluationContext context(final EvaluateRequest request) { + final MutableContext context = new MutableContext(); + context.setTargetingKey(request.getTargetingKey()); + request.attributes.forEach((key, value) -> { + if (value instanceof Boolean) { + context.add(key, (Boolean) value); + } else if (value instanceof Integer) { + context.add(key, (Integer) value); + } else if (value instanceof Double) { + context.add(key, (Double) value); + } else if (value instanceof String) { + context.add(key, (String) value); + } else if (value instanceof Map) { + context.add(key, Value.objectToValue(value).asStructure()); + } else if (value instanceof List) { + context.add(key, Value.objectToValue(value).asList()); + } else { + context.add(key, (Structure) null); + } + }); + return context; + } + + public static class EvaluateRequest { + private String flag; + @JsonAlias("variation_type") + private String variationType; + @JsonAlias("default_value") + private Object defaultValue; + @JsonAlias("targeting_key") + private String targetingKey; + private Map attributes; + + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + public Object getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(Object defaultValue) { + this.defaultValue = defaultValue; + } + + public String getFlag() { + return flag; + } + + public void setFlag(String flag) { + this.flag = flag; + } + + public String getTargetingKey() { + return targetingKey; + } + + public void setTargetingKey(String targetingKey) { + this.targetingKey = targetingKey; + } + + public String getVariationType() { + return variationType; + } + + public void setVariationType(String variationType) { + this.variationType = variationType; + } + } +} diff --git a/utils/build/docker/java/spring-boot/pom.xml b/utils/build/docker/java/spring-boot/pom.xml index 92c10566a56..00f62d3fce8 100644 --- a/utils/build/docker/java/spring-boot/pom.xml +++ b/utils/build/docker/java/spring-boot/pom.xml @@ -46,6 +46,11 @@ dd-trace-api ${dd-trace-api.version} + + com.datadoghq + dd-openfeature + ${dd-openfeature.version} + io.opentelemetry opentelemetry-api @@ -224,6 +229,11 @@ okhttp 4.9.3 + + dev.openfeature + sdk + 1.18.2 + @@ -492,6 +502,7 @@ 1.65.1 jar [1.47.0,) + [1.56.0,) diff --git a/utils/build/docker/java/spring-boot/src/main/java/com/datadoghq/system_tests/springboot/featureflag/FeatureFlagEvaluatorController.java b/utils/build/docker/java/spring-boot/src/main/java/com/datadoghq/system_tests/springboot/featureflag/FeatureFlagEvaluatorController.java new file mode 100644 index 00000000000..25098eee4a4 --- /dev/null +++ b/utils/build/docker/java/spring-boot/src/main/java/com/datadoghq/system_tests/springboot/featureflag/FeatureFlagEvaluatorController.java @@ -0,0 +1,177 @@ +package com.datadoghq.system_tests.springboot.featureflag; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import datadog.trace.api.openfeature.Provider; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.NoOpProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +public class FeatureFlagEvaluatorController { + + @Configuration + public static class FeatureFlagEvaluatorConfig { + + @Lazy + @Bean + public Client client() { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final String envProperty = System.getenv("DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"); + final FeatureProvider provider; + if (Boolean.parseBoolean(envProperty)) { + provider = new Provider(); + } else { + provider = new NoOpProvider() { + @Override + public ProviderState getState() { + return ProviderState.READY; + } + }; + } + api.setProviderAndWait(provider); + return api.getClient(); + } + } + + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagEvaluatorController.class); + + @Autowired + @Lazy + private Client client; + + @PostMapping(value = "/ffe", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) + public ResponseEntity> evaluate(@RequestBody final EvaluateRequest request) { + Object value; + String reason; + final EvaluationContext context = context(request); + try { + switch (request.getVariationType()) { + case "BOOLEAN": + value = client.getBooleanValue(request.getFlag(), (Boolean) request.getDefaultValue(), context); + break; + case "STRING": + value = client.getStringValue(request.getFlag(), (String) request.getDefaultValue(), context); + break; + case "INTEGER": + value = client.getIntegerValue(request.getFlag(), (Integer) request.getDefaultValue(), context); + break; + case "NUMERIC": + final Number number = (Number) request.getDefaultValue(); + if (number instanceof Double) { + value = client.getDoubleValue(request.getFlag(), number.doubleValue(), context); + } else { + value = client.getIntegerValue(request.getFlag(), number.intValue(), context); + } + break; + case "JSON": + final Value objectValue = client.getObjectValue(request.getFlag(), Value.objectToValue(request.getDefaultValue()), context); + value = context.convertValue(objectValue); + break; + default: + value = request.getDefaultValue(); + } + + reason = "DEFAULT"; + } catch (Throwable e) { + LOGGER.error("Error on resolution", e); + value = request.getDefaultValue(); + reason = "ERROR"; + } + final Map result = new HashMap<>(); + result.put("reason", reason); + result.put("value", value); + return ResponseEntity.ok(result); + } + + private static EvaluationContext context(final EvaluateRequest request) { + final MutableContext context = new MutableContext(); + context.setTargetingKey(request.getTargetingKey()); + request.attributes.forEach((key, value) -> { + if (value instanceof Boolean) { + context.add(key, (Boolean) value); + } else if (value instanceof Integer) { + context.add(key, (Integer) value); + } else if (value instanceof Double) { + context.add(key, (Double) value); + } else if (value instanceof String) { + context.add(key, (String) value); + } else if (value instanceof Map) { + context.add(key, Value.objectToValue(value).asStructure()); + } else if (value instanceof List) { + context.add(key, Value.objectToValue(value).asList()); + } else { + context.add(key, (Structure) null); + } + }); + return context; + } + + public static class EvaluateRequest { + private String flag; + private String variationType; + private Object defaultValue; + private String targetingKey; + private Map attributes; + + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + public Object getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(Object defaultValue) { + this.defaultValue = defaultValue; + } + + public String getFlag() { + return flag; + } + + public void setFlag(String flag) { + this.flag = flag; + } + + public String getTargetingKey() { + return targetingKey; + } + + public void setTargetingKey(String targetingKey) { + this.targetingKey = targetingKey; + } + + public String getVariationType() { + return variationType; + } + + public void setVariationType(String variationType) { + this.variationType = variationType; + } + } +}