diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index df0fca12659..073402ea01e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1070,6 +1070,16 @@ test_smoke: parallel: matrix: *test_matrix_8 +test_openfeature_compatibility: + extends: .test_job_amd64_with_test_agent + needs: *needs_build_tests_smoke + variables: + <<: *tier_l_variables + GRADLE_TARGET: "stageMainDist :dd-smoke-tests:openfeature:openFeatureCompatibilityTest" + GRADLE_PARAMS: "-PskipFlakyTests" + CACHE_TYPE: "smoke" + testJvm: "11" + test_smoke_arm64: extends: .test_job_arm64_with_test_agent variables: diff --git a/dd-smoke-tests/openfeature/application/build.gradle b/dd-smoke-tests/openfeature/application/build.gradle index 9f7f9558e9b..f16520fc695 100644 --- a/dd-smoke-tests/openfeature/application/build.gradle +++ b/dd-smoke-tests/openfeature/application/build.gradle @@ -16,6 +16,16 @@ if (hasProperty('appBuildDir')) { version = "" +configurations.configureEach { + resolutionStrategy.componentSelection.all { selection -> + if (selection.candidate.group == 'dev.openfeature' && + selection.candidate.module == 'sdk' && + selection.candidate.version.endsWith('-SNAPSHOT')) { + selection.reject('Compatibility tests require the latest stable release') + } + } +} + // Java 11 (not Java 8 like the other Spring Boot 2.x smoke tests): the OpenFeature SDK // requires Java 11+. This matches what the outer (pre-nested) build was doing before // the application was extracted into this nested build. @@ -24,15 +34,25 @@ java { targetCompatibility = 11 } +if (hasProperty('featureFlaggingApiJar') && hasProperty('ddOpenFeatureVersion')) { + throw new GradleException('Specify either featureFlaggingApiJar or ddOpenFeatureVersion, not both') +} + if (hasProperty('featureFlaggingApiJar')) { dependencies { implementation files(property('featureFlaggingApiJar')) } +} else if (hasProperty('ddOpenFeatureVersion')) { + dependencies { + implementation "com.datadoghq:dd-openfeature:${property('ddOpenFeatureVersion')}" + } +} else { + throw new GradleException('featureFlaggingApiJar or ddOpenFeatureVersion is required') } dependencies { // OpenFeature SDK is an API dependency of feature-flagging-api but is not // transitively resolved when the jar is passed as a files() dependency. - implementation 'dev.openfeature:sdk:1.20.1' + implementation "dev.openfeature:sdk:${findProperty('openFeatureSdkVersion') ?: '1.20.1'}" implementation 'org.springframework.boot:spring-boot-starter-web' } diff --git a/dd-smoke-tests/openfeature/build.gradle b/dd-smoke-tests/openfeature/build.gradle index 726b6068fe9..31d6cb640a1 100644 --- a/dd-smoke-tests/openfeature/build.gradle +++ b/dd-smoke-tests/openfeature/build.gradle @@ -1,3 +1,6 @@ +import datadog.buildlogic.smoketest.NestedGradleBuild +import org.gradle.process.CommandLineArgumentProvider + plugins { id 'dd-trace-java.smoke-test-app' id 'dd-trace-java.module.smoke-test' @@ -5,6 +8,23 @@ plugins { description = 'Open Feature provider Smoke Tests.' +def minimumSupportedOpenFeatureSdkVersion = '1.20.1' +def latestOpenFeatureSdkVersion = '+' +def minimumSupportedAgentVersion = '1.65.0' + +configurations { + register('developmentProvider') { + canBeConsumed = false + canBeResolved = true + transitive = false + } + register('minimumSupportedAgent') { + canBeConsumed = false + canBeResolved = true + transitive = false + } +} + testJvmConstraints { minJavaVersion = JavaVersion.VERSION_11 } @@ -21,6 +41,118 @@ smokeTestApp { dependencies { testImplementation project(':dd-smoke-tests') testImplementation project(':products:feature-flagging:feature-flagging-lib') + + developmentProvider project(':products:feature-flagging:feature-flagging-api') + minimumSupportedAgent "com.datadoghq:dd-java-agent:${minimumSupportedAgentVersion}" +} + +def developmentProviderJar = configurations.developmentProvider.elements.map { files -> + objects.fileProperty().fileValue(files.iterator().next().asFile).get() +} + +def registerCompatibilityApp = { String name, String providerVersion, String sdkVersion -> + def appBuildDir = layout.buildDirectory.dir("application-${name}") + def appTask = tasks.register("${name}App", NestedGradleBuild) { + applicationDir = layout.projectDirectory.dir('application') + applicationBuildDir = appBuildDir + tasksToRun = ['bootJar'] + buildArguments.add("-PopenFeatureSdkVersion=${sdkVersion}") + if (providerVersion == null) { + projectJar('featureFlaggingApiJar', developmentProviderJar) + } else { + buildArguments.add("-PddOpenFeatureVersion=${providerVersion}") + } + } + return [ + task: appTask, + jar: appBuildDir.map { + it.file('libs/openfeature-smoketest.jar') + } + ] +} + +def registerCompatibilityTest = { String name, Map app, Configuration agent, String testClass -> + tasks.register(name, Test) { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Runs the OpenFeature smoke suite with ${name - 'Test'}." + useJUnitPlatform() + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + include "**/${testClass}.*" + dependsOn app.task + inputs.file(app.jar) + if (agent != null) { + inputs.files(agent) + } + jvmArgumentProviders.add(new CommandLineArgumentProvider() { + @Override + Iterable asArguments() { + final arguments = [ + "-Ddatadog.smoketest.openfeature.application.path=${app.jar.get().asFile.absolutePath}" + ] + if (agent != null) { + arguments.add("-Ddatadog.smoketest.openfeature.agent.path=${agent.singleFile.absolutePath}") + } + return arguments + } + }) + } +} + +tasks.named('test', Test) { + exclude '**/OpenFeatureCompatibilitySmokeTest.*' +} + +def minimumSdkApp = registerCompatibilityApp( + 'minimumSdk', + null, + minimumSupportedOpenFeatureSdkVersion + ) +def latestSdkMinimumAgentApp = registerCompatibilityApp( + 'latestSdkMinimumAgent', + // Keep the provider aligned with the minimum agent's internal API. + minimumSupportedAgentVersion, + latestOpenFeatureSdkVersion + ) +def latestDevelopmentVersionsApp = registerCompatibilityApp( + 'latestDevelopmentVersions', + null, + latestOpenFeatureSdkVersion + ) + +def minimumSdkLatestAgentTest = registerCompatibilityTest( + 'minimumSdkLatestAgentTest', + minimumSdkApp, + null, + 'OpenFeatureCompatibilitySmokeTest' + ) +def latestSdkMinimumAgentTest = registerCompatibilityTest( + 'latestSdkMinimumAgentTest', + latestSdkMinimumAgentApp, + configurations.minimumSupportedAgent, + 'OpenFeatureCompatibilitySmokeTest' + ) +def latestDevelopmentVersionsTest = registerCompatibilityTest( + 'latestDevelopmentVersionsTest', + latestDevelopmentVersionsApp, + null, + 'OpenFeatureProviderSmokeTest' + ) + +minimumSdkLatestAgentTest.configure { + mustRunAfter tasks.named('test') +} +latestSdkMinimumAgentTest.configure { + mustRunAfter minimumSdkLatestAgentTest +} +latestDevelopmentVersionsTest.configure { + mustRunAfter latestSdkMinimumAgentTest +} + +tasks.register('openFeatureCompatibilityTest') { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = 'Tests supported OpenFeature SDK versions with minimum and development agents.' + dependsOn minimumSdkLatestAgentTest, latestSdkMinimumAgentTest, latestDevelopmentVersionsTest } spotless { diff --git a/dd-smoke-tests/openfeature/gradle.lockfile b/dd-smoke-tests/openfeature/gradle.lockfile index 2343b4b58c8..ebf6563875d 100644 --- a/dd-smoke-tests/openfeature/gradle.lockfile +++ b/dd-smoke-tests/openfeature/gradle.lockfile @@ -10,6 +10,7 @@ com.blogspot.mydailyjava:weak-lock-free:0.17=testCompileClasspath,testRuntimeCla com.datadoghq.okhttp3:okhttp:3.12.15=testCompileClasspath,testRuntimeClasspath com.datadoghq.okio:okio:1.17.6=testCompileClasspath,testRuntimeClasspath com.datadoghq:dd-instrument-java:0.0.4=testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-java-agent:1.65.0=minimumSupportedAgent com.datadoghq:dd-javac-plugin-client:0.2.2=testCompileClasspath,testRuntimeClasspath com.datadoghq:java-dogstatsd-client:4.4.5=testRuntimeClasspath com.datadoghq:sketches-java:0.8.3=testRuntimeClasspath diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy index 12115cb407d..62f4637e15b 100644 --- a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy @@ -16,26 +16,29 @@ import spock.lang.Stepwise import spock.lang.Unroll import spock.util.concurrent.PollingConditions -/** Due to the exposure cache it's important to run the tests in the specified order */ -@Stepwise -class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { +abstract class AbstractOpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { @Shared - private final rcConfig = new JsonSlurper().parse(fetchResource("ffe-system-test-data/ufc-config.json")) as Map + protected final rcConfig = new JsonSlurper().parse(fetchResource('ffe-system-test-data/ufc-config.json')) as Map @Shared - private final rcPayload = JsonOutput.toJson(rcConfig) - - @Shared - private final loggedAllocations = buildLoggedAllocations(rcConfig) + protected final rcPayload = JsonOutput.toJson(rcConfig) @Override ProcessBuilder createProcessBuilder() { - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) + setRemoteConfig('datadog/2/FFE_FLAGS/1/config', rcPayload) - final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") + final springBootShadowJar = System.getProperty( + 'datadog.smoketest.openfeature.application.path', + System.getProperty('datadog.smoketest.springboot.shadowJar.path') + ) + final agentJar = System.getProperty('datadog.smoketest.openfeature.agent.path', shadowJarPath) + assert Files.isRegularFile(Paths.get(springBootShadowJar)) + assert Files.isRegularFile(Paths.get(agentJar)) final command = [javaPath()] - command.addAll(defaultJavaProperties) + command.addAll(defaultJavaProperties.collect { + it.startsWith('-javaagent:') ? "-javaagent:${agentJar}".toString() : it + }) command.add('-Ddd.trace.debug=true') command.add('-Ddd.remote_config.enabled=true') command.add("-Ddd.remote_config.url=http://localhost:${server.address.port}/v0.7/config".toString()) @@ -62,6 +65,35 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { } } + protected static URL fetchResource(final String name) { + return Thread.currentThread().getContextClassLoader().getResource(name) + } + + protected static Set decodeProducts(final Map request) { + return request.client.products.collect { Product.valueOf(it) } + } + + protected static long decodeCapabilities(final Map request) { + final clientCapabilities = request.client.capabilities as byte[] + long capabilities = 0l + for (int i = 0; i < clientCapabilities.length; i++) { + capabilities |= (clientCapabilities[i] & 0xFFL) << ((clientCapabilities.length - i - 1) * 8) + } + return capabilities + } + + protected static boolean hasCapability(final long capabilities, final long test) { + return (capabilities & test) > 0 + } +} + +/** Due to the exposure cache it's important to run the tests in the specified order */ +@Stepwise +class OpenFeatureProviderSmokeTest extends AbstractOpenFeatureProviderSmokeTest { + + @Shared + private final loggedAllocations = buildLoggedAllocations(rcConfig) + void 'test first remote config poll asks agent for feature flags'() { when: final firstRcRequest = waitForRcClientRequest { req -> @@ -179,10 +211,6 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { testCase << parseTestCases() } - private static URL fetchResource(final String name) { - return Thread.currentThread().getContextClassLoader().getResource(name) - } - private static List> parseTestCases() { final folder = fetchResource('ffe-system-test-data/evaluation-cases') final uri = folder.toURI() @@ -250,21 +278,64 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { } return logged } +} - private static Set decodeProducts(final Map request) { - return request.client.products.collect { Product.valueOf(it) } - } +/** Stable contract shared by all supported OpenFeature SDK and dd-java-agent versions. */ +@Stepwise +class OpenFeatureCompatibilitySmokeTest extends AbstractOpenFeatureProviderSmokeTest { - private static long decodeCapabilities(final Map request) { - final clientCapabilities = request.client.capabilities as byte[] - long capabilities = 0l - for (int i = 0; i < clientCapabilities.length; i++) { - capabilities |= (clientCapabilities[i] & 0xFFL) << ((clientCapabilities.length - i - 1) * 8) + void 'test agent advertises feature flag remote configuration'() { + when: + final firstRcRequest = waitForRcClientRequest { req -> + return true } - return capabilities + + then: + firstRcRequest == rcClientMessages.first() + decodeProducts(firstRcRequest).contains(Product.FFE_FLAGS) + hasCapability( + decodeCapabilities(firstRcRequest), + Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES + ) } - private static boolean hasCapability(final long capabilities, final long test) { - return (capabilities & test) > 0 + void 'test provider evaluates a flag and reports its exposure'() { + setup: + setRemoteConfig('datadog/2/FFE_FLAGS/1/config', rcPayload) + final request = new Request.Builder() + .url("http://localhost:${httpPort}/openfeature/evaluate") + .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson([ + flag: 'boolean-false-assignment', + variationType: 'BOOLEAN', + defaultValue: true, + targetingKey: 'compatibility-test', + attributes: [should_disable_feature: true] + ]))) + .build() + + when: + final response = client.newCall(request).execute() + final responseBody = new JsonSlurper().parse(response.body().byteStream()) + + then: + response.code() == 200 + responseBody.value == false + responseBody.reason == 'TARGETING_MATCH' + responseBody.variant == 'false-variation' + responseBody.flagMetadata.allocationKey == 'disable-feature' + new PollingConditions(timeout: 10).eventually { + final requests = evpProxyMessages*.getV2() as List> + final events = requests*.exposures.flatten() + assert events.find { + event -> + event.flag.key == 'boolean-false-assignment' && + event.allocation.key == 'disable-feature' && + event.variant.key == 'false-variation' && + event.subject.id == 'compatibility-test' + } != null + } + + cleanup: + response.close() } } diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index b20efb17bd7..e3fbfee3f29 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -1,5 +1,6 @@ import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension import groovy.lang.Closure +import org.gradle.api.plugins.jvm.JvmTestSuite plugins { `java-library` @@ -60,6 +61,22 @@ dependencies { jmhImplementation(project(":utils:config-utils")) } +testing { + suites { + register("legacyOpenFeatureSdkTest") { + dependencies { + implementation(project()) + } + } + } +} + +// Compile the compatibility test against the supported API, then replace only its runtime SDK +// with the last unsupported release so it exercises the real return-type linkage failure. +configurations.named("legacyOpenFeatureSdkTestRuntimeClasspath") { + resolutionStrategy.force("dev.openfeature:sdk:1.15.1") +} + jmh { jmhVersion = libs.versions.jmh.get() duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE diff --git a/products/feature-flagging/feature-flagging-api/src/legacyOpenFeatureSdkTest/java/datadog/trace/api/openfeature/LegacyOpenFeatureSdkTest.java b/products/feature-flagging/feature-flagging-api/src/legacyOpenFeatureSdkTest/java/datadog/trace/api/openfeature/LegacyOpenFeatureSdkTest.java new file mode 100644 index 00000000000..ab85919546b --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/legacyOpenFeatureSdkTest/java/datadog/trace/api/openfeature/LegacyOpenFeatureSdkTest.java @@ -0,0 +1,72 @@ +package datadog.trace.api.openfeature; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.openfeature.Provider.Options; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class LegacyOpenFeatureSdkTest { + + @Test + void unsupportedSdkLinkageFailureIsContainedAndReportedOnce() throws Exception { + assertEquals("1.15.1", Provider.openFeatureSdkVersion()); + final AtomicInteger compatibilityWarnings = new AtomicInteger(); + final AtomicReference reportedError = new AtomicReference<>(); + final Provider provider = + new Provider(new Options(), configuredEvaluator(), Boolean.FALSE) { + @Override + void reportOpenFeatureSdkIncompatibility(final LinkageError error) { + compatibilityWarnings.incrementAndGet(); + reportedError.set(error); + } + }; + provider.initialize(null); + + assertDoesNotThrow(provider::onConfigurationChange); + assertDoesNotThrow(provider::onConfigurationChange); + + assertEquals(1, compatibilityWarnings.get()); + assertInstanceOf(NoSuchMethodError.class, reportedError.get()); + assertTrue( + reportedError.get().getMessage().contains("datadog.trace.api.openfeature.Provider.emit"), + reportedError.get()::toString); + assertTrue( + Provider.openFeatureSdkCompatibilityWarning(reportedError.get()) + .contains("detected version: 1.15.1")); + } + + private static Evaluator configuredEvaluator() { + return new Evaluator() { + @Override + public boolean initialize( + final long timeout, final TimeUnit timeUnit, final EvaluationContext context) { + return true; + } + + @Override + public boolean hasConfiguration() { + return true; + } + + @Override + public void shutdown() {} + + @Override + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + return null; + } + }; + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java index 17009d9933a..2fa2e0b98c2 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -15,11 +15,15 @@ import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.OpenFeatureError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Properties; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,12 +33,17 @@ public class Provider extends EventProvider implements Metadata { private static final Logger log = LoggerFactory.getLogger(Provider.class); static final String METADATA = "datadog-openfeature-provider"; private static final String EVALUATOR_IMPL = "datadog.trace.api.openfeature.DDEvaluator"; + private static final String MINIMUM_OPENFEATURE_SDK_VERSION = "1.20.1"; + private static final String OPENFEATURE_SDK_POM_PROPERTIES = + "/META-INF/maven/dev.openfeature/sdk/pom.properties"; + private static final String UNKNOWN_VERSION = "unknown"; private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); private volatile Evaluator evaluator; private final Options options; private final AtomicReference initializationState = new AtomicReference<>(InitializationState.NOT_STARTED); + private final AtomicBoolean openFeatureSdkCompatibilityWarningLogged = new AtomicBoolean(); private final FlagEvalMetrics flagEvalMetrics; private final FlagEvalMetricsHook flagEvalMetricsHook; // Span enrichment: null unless the gate is on, so the feature has no idle overhead when off. @@ -161,17 +170,14 @@ void onConfigurationChange() { if (state == InitializationState.ERROR && initializationState.compareAndSet( InitializationState.ERROR, InitializationState.READY)) { - emit( - ProviderEvent.PROVIDER_READY, - ProviderEventDetails.builder().message("Provider ready").build()); + emitProviderEvent(ProviderEvent.PROVIDER_READY, "Provider ready", null); return; } if (initializationState.get() != InitializationState.READY) { return; } - emit( - ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, - ProviderEventDetails.builder().message("New configuration received").build()); + emitProviderEvent( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, "New configuration received", null); } private void onConfigurationUnavailable() { @@ -182,12 +188,66 @@ private void onConfigurationUnavailable() { if (!initializationState.compareAndSet(InitializationState.READY, InitializationState.ERROR)) { return; } - emit( - ProviderEvent.PROVIDER_ERROR, - ProviderEventDetails.builder() - .message("Configuration unavailable") - .errorCode(ErrorCode.PROVIDER_NOT_READY) - .build()); + emitProviderEvent( + ProviderEvent.PROVIDER_ERROR, "Configuration unavailable", ErrorCode.PROVIDER_NOT_READY); + } + + private void emitProviderEvent( + final ProviderEvent event, final String message, final ErrorCode errorCode) { + try { + if (errorCode == null) { + emit(event, ProviderEventDetails.builder().message(message).build()); + } else { + emit(event, ProviderEventDetails.builder().message(message).errorCode(errorCode).build()); + } + } catch (final LinkageError error) { + if (openFeatureSdkCompatibilityWarningLogged.compareAndSet(false, true)) { + reportOpenFeatureSdkIncompatibility(error); + } + } + } + + void reportOpenFeatureSdkIncompatibility(final LinkageError error) { + log.warn(openFeatureSdkCompatibilityWarning(error)); + log.debug("OpenFeature SDK compatibility failure", error); + } + + static String openFeatureSdkCompatibilityWarning(final LinkageError error) { + return "Unable to emit OpenFeature provider events because the loaded OpenFeature SDK is " + + "incompatible (detected version: " + + openFeatureSdkVersion() + + "). Datadog requires dev.openfeature:sdk version " + + MINIMUM_OPENFEATURE_SDK_VERSION + + " or later. Upgrade the OpenFeature SDK dependency. Further provider event emission " + + "failures will be suppressed. Cause: " + + error; + } + + static String openFeatureSdkVersion() { + try { + final Package sdkPackage = EventProvider.class.getPackage(); + if (sdkPackage != null && sdkPackage.getImplementationVersion() != null) { + return sdkPackage.getImplementationVersion(); + } + try (InputStream input = + EventProvider.class.getResourceAsStream(OPENFEATURE_SDK_POM_PROPERTIES)) { + if (input != null) { + final String version = loadOpenFeatureSdkVersion(input); + if (version != null && !version.isEmpty()) { + return version; + } + } + } + } catch (final IOException | RuntimeException | LinkageError ignored) { + // Version detection is best-effort and must not interfere with compatibility handling. + } + return UNKNOWN_VERSION; + } + + private static String loadOpenFeatureSdkVersion(final InputStream input) throws IOException { + final Properties properties = new Properties(); + properties.load(input); + return properties.getProperty("version"); } private boolean markInitialConfigReceivedReady() { diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java index d7adf645e74..820a97297d8 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java @@ -5,13 +5,16 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -43,6 +46,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.junit.jupiter.api.AfterEach; @@ -298,6 +302,48 @@ public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throw verify(configChangedEventHandler, times(1)).accept(any()); } + @Test + public void testProviderEventLinkageErrorIsContainedAndReportedOnce() throws Exception { + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(10L), eq(MILLISECONDS), any())).thenReturn(true); + when(evaluator.hasConfiguration()).thenReturn(true); + final AtomicInteger compatibilityWarnings = new AtomicInteger(); + final AtomicReference reportedError = new AtomicReference<>(); + final Provider provider = + spy( + new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator, Boolean.FALSE) { + @Override + void reportOpenFeatureSdkIncompatibility(final LinkageError error) { + compatibilityWarnings.incrementAndGet(); + reportedError.set(error); + } + }); + final NoSuchMethodError linkageError = + new NoSuchMethodError( + "dev.openfeature.sdk.EventProvider.emit(ProviderEvent, ProviderEventDetails)"); + doThrow(linkageError).when(provider).emit(any(), any()); + provider.initialize(null); + + assertDoesNotThrow(provider::onConfigurationChange); + assertDoesNotThrow(provider::onConfigurationChange); + + verify(provider, times(2)).emit(any(), any()); + assertThat(compatibilityWarnings.get(), equalTo(1)); + assertThat(reportedError.get(), equalTo(linkageError)); + } + + @Test + public void testOpenFeatureSdkCompatibilityWarningIsActionable() { + final String warning = + Provider.openFeatureSdkCompatibilityWarning( + new NoSuchMethodError("dev.openfeature.sdk.EventProvider.emit")); + + assertTrue(warning.contains("detected version: 1.20.1")); + assertTrue(warning.contains("dev.openfeature:sdk version 1.20.1 or later")); + assertTrue(warning.contains("Upgrade the OpenFeature SDK dependency")); + assertTrue(warning.contains("dev.openfeature.sdk.EventProvider.emit")); + } + @Test public void testFailureToLoadInternalApi() { @SuppressWarnings("unchecked")