From 5567f9b721f01dab19d871a0cbf2e28c01d4c419 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 21 Oct 2026 13:47:21 +0200 Subject: [PATCH 1/2] feat(ffe): Add agentless and standalone OpenFeature smoke tests Extract the shared OpenFeature smoke test scenarios and run them against the SDK alone, the SDK with the agent in agentless mode, and the SDK with the agent through Remote Configuration. Document the new SDK layout in ARCHITECTURE.md. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 14 +- .../AbstractOpenFeatureSmokeTest.groovy | 213 ++++++++++++++++++ .../OpenFeatureAgentlessSmokeTest.groovy | 24 ++ .../OpenFeatureProviderSmokeTest.groovy | 185 +-------------- .../OpenFeatureStandaloneSmokeTest.groovy | 35 +++ 5 files changed, 290 insertions(+), 181 deletions(-) create mode 100644 dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/AbstractOpenFeatureSmokeTest.groovy create mode 100644 dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureAgentlessSmokeTest.groovy create mode 100644 dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureStandaloneSmokeTest.groovy diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1b67fae9ceb..985b323d8c6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,8 @@ to `.classdata` to prevent unintended loading. See [`docs/how_to_work_with_gradl - **`agent-tooling/`** — Instrumentation framework. Key types: - `InstrumenterModule` — Base class for all instrumentation modules. Declares a target system - (Tracing, AppSec, IAST, Profiling, CiVisibility, USM, etc.) and one or more instrumentations. + (Tracing, AppSec, IAST, Profiling, CiVisibility, USM, FeatureFlags, etc.) and one or more + instrumentations. - `Instrumenter` — Type matching interface: `ForSingleType`, `ForKnownTypes`, `ForTypeHierarchy`, `ForBootstrap`. - `muzzle/` — Build-time and runtime safety checks. Verifies that expected types and methods @@ -229,9 +230,8 @@ Current products: - `metrics/` — StatsD client and monitoring abstraction. Provides `Monitoring` interface with counters, timers, and histograms for internal agent metrics collection. -- `feature-flagging/` — Server-side feature flag evaluation driven by remote configuration. - Implements the OpenFeature SDK, handles the Unified Feature Control (UFC) protocol, - and tracks flag exposure per user/session. +- `feature-flagging/` — Agent services for the [`dd-openfeature/`](#dd-openfeature) SDK: + remote configuration, event delivery through the Datadog Agent, and span enrichment. ### `communication/` @@ -279,6 +279,12 @@ that implements the `io.opentracing.Tracer` interface by wrapping the Datadog `C This is a pure library for manual instrumentation only — there is no auto-instrumentation or bytecode advice. +### `dd-openfeature/` + +Feature Flags OpenFeature provider. Publishes a standalone JAR artifact (`dd-openfeature.jar`, Java 11+) +that works without the agent. When the agent is attached, an instrumentation connects it to the +`products/feature-flagging/` agent services. + ### `dd-smoke-tests/` End-to-end smoke tests. Each boots a real application with the agent jar and verifies traces, spans, diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/AbstractOpenFeatureSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/AbstractOpenFeatureSmokeTest.groovy new file mode 100644 index 00000000000..147c6fa2fde --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/AbstractOpenFeatureSmokeTest.groovy @@ -0,0 +1,213 @@ +package datadog.smoketest.springboot + +import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer + +import datadog.smoketest.AbstractServerSmokeTest +import datadog.trace.agent.test.server.http.TestHttpServer +import datadog.trace.agent.test.server.http.TestHttpServer.HandlerApi.RequestApi +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import java.nio.file.Files +import java.nio.file.Paths +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import spock.lang.AutoCleanup +import spock.lang.IgnoreIf +import spock.lang.Shared +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 +abstract class AbstractOpenFeatureSmokeTest extends AbstractServerSmokeTest { + + @Shared + protected final ufcConfig = new JsonSlurper().parse(fetchResource("ffe-system-test-data/ufc-config.json")) as Map + + @Shared + protected final ufcPayload = JsonOutput.toJson(ufcConfig) + + @Shared + protected final loggedAllocations = buildLoggedAllocations(ufcConfig) + + /** Serves the flag configuration as the Datadog managed CDN does. */ + @Shared + @AutoCleanup + protected TestHttpServer cdn = httpServer { + handlers { + prefix("/api/v2/feature-flagging/config/rules-based/server") { + response.status(200).send(JsonOutput.toJson([ + data: [type: 'universal-flag-configuration', attributes: ufcConfig] + ])) + } + } + } + + /** @return whether exposures are delivered through the Datadog Agent event platform proxy. */ + abstract boolean deliversExposures() + + /** Publishes the flag configuration before each evaluation. */ + void publishConfiguration() {} + + @Override + Closure decodedTracesCallback() { + return {} + } + + @Override + Closure decodedEvpProxyMessageCallback() { + return { String path, RequestApi request -> + if (!path.contains('api/v2/exposures')) { + return null + } + return new JsonSlurper().parse(request.body) + } + } + + @IgnoreIf({ !instance.deliversExposures() }) + void 'test open feature exposures'() { + setup: + publishConfiguration() + final testCases = parseTestCases() + assert !testCases.isEmpty() + + when: + final results = testCases.collect { + testCase -> + final response = evaluate(testCase) + final responseBody = new JsonSlurper().parse(response.body().byteStream()) + return [testCase: testCase, response: response, body: responseBody] + } + final expectedExposures = uniqueExpectedExposures(results) + + then: + results.every { + it.response.code() == 200 + } + !expectedExposures.isEmpty() + new PollingConditions(timeout: 10).eventually { + final requests = evpProxyMessages*.getV2() as List> + final events = requests*.exposures.flatten() + assert events.size() == expectedExposures.size() + expectedExposures.each { + expected -> + assert events.find { + event -> + event.flag.key == expected.flag && + event.allocation.key == expected.allocation && + event.variant.key == expected.variant && + event.subject.id == expected.targetingKey + } != null : "Unable to find exposure ${expected}" + } + } + } + + @Unroll("test open feature evaluation - #testCase.fileName[#testCase.index] - flag=#testCase.flag") + void 'test open feature evaluation'() { + setup: + publishConfiguration() + + when: + final response = evaluate(testCase) + + then: + response.code() == 200 + final responseBody = new JsonSlurper().parse(response.body().byteStream()) + responseBody.value == testCase.result.value + responseBody.reason == testCase.result.reason + if (testCase.result.containsKey('errorCode')) { + assert responseBody.errorCode == testCase.result.errorCode + } + if (testCase.result.containsKey('variant')) { + assert responseBody.variant == testCase.result.variant + } + if (testCase.result.flagMetadata?.allocationKey) { + assert responseBody.flagMetadata?.allocationKey == testCase.result.flagMetadata?.allocationKey + } + + where: + testCase << parseTestCases() + } + + protected okhttp3.Response evaluate(final Map testCase) { + final request = new Request.Builder() + .url("http://localhost:${httpPort}/openfeature/evaluate") + .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase))) + .build() + return client.newCall(request).execute() + } + + protected static URL fetchResource(final String name) { + return Thread.currentThread().getContextClassLoader().getResource(name) + } + + protected static List> parseTestCases() { + final folder = fetchResource('ffe-system-test-data/evaluation-cases') + final uri = folder.toURI() + final testsPath = Paths.get(uri) + final files = Files.list(testsPath) + .filter(path -> path.toString().endsWith('.json')) + .sorted(Comparator.comparing(path -> path.fileName.toString())) + final result = [] + final slurper = new JsonSlurper() + files.each { + path -> + final testCases = slurper.parse(path.toFile()) as List> + testCases.eachWithIndex { + testCase, index -> + testCase.fileName = path.fileName.toString() + testCase.index = index + } + result.addAll(testCases) + } + assert !result.isEmpty() + return result + } + + protected List> uniqueExpectedExposures(final List> results) { + final expected = [] + final seen = [] as Set + results.each { result -> + final testCase = result.testCase as Map + final body = result.body as Map + final flag = testCase.flag as String + final allocation = body.flagMetadata?.allocationKey as String + final variant = body.variant as String + if (!variant || !allocation || !allocationLogs(flag, allocation)) { + return + } + + final exposure = [ + flag: flag, + allocation: allocation, + variant: variant, + targetingKey: testCase.targetingKey + ] + final key = "${exposure.flag}\u0000${exposure.targetingKey}\u0000${exposure.allocation}\u0000${exposure.variant}" + if (seen.add(key)) { + expected.add(exposure) + } + } + return expected + } + + protected boolean allocationLogs(final String flag, final String allocation) { + return loggedAllocations["${flag}\u0000${allocation}"] == true + } + + protected static Map buildLoggedAllocations(final Map config) { + final logged = [:] + (config.flags as Map).each { flag, definition -> + if (definition.allocations instanceof List) { + definition.allocations.each { allocation -> + if (allocation instanceof Map) { + logged["${flag}\u0000${allocation.key}"] = allocation.doLog == true + } + } + } + } + return logged + } +} diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureAgentlessSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureAgentlessSmokeTest.groovy new file mode 100644 index 00000000000..bf7932f7ea8 --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureAgentlessSmokeTest.groovy @@ -0,0 +1,24 @@ +package datadog.smoketest.springboot + +/** + * The SDK with the Datadog Java agent and the default agentless configuration source: the SDK + * polls the flag configuration from the CDN and delivers exposures through the agent. + */ +class OpenFeatureAgentlessSmokeTest extends AbstractOpenFeatureSmokeTest { + + @Override + ProcessBuilder createProcessBuilder() { + final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") + final command = [javaPath()] + command.addAll(defaultJavaProperties) + command.addAll(['-jar', springBootShadowJar, "--server.port=${httpPort}".toString()]) + final builder = new ProcessBuilder(command).directory(new File(buildDirectory)) + builder.environment().put('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL', "http://localhost:${cdn.address.port}".toString()) + return builder + } + + @Override + boolean deliversExposures() { + return true + } +} 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..d0808be6aea 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 @@ -2,36 +2,18 @@ package datadog.smoketest.springboot import datadog.remoteconfig.Capabilities import datadog.remoteconfig.Product -import datadog.smoketest.AbstractServerSmokeTest -import datadog.trace.agent.test.server.http.TestHttpServer.HandlerApi.RequestApi import groovy.json.JsonOutput import groovy.json.JsonSlurper -import java.nio.file.Files -import java.nio.file.Paths import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody -import spock.lang.Shared -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 { - - @Shared - private 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) +/** The SDK with the Datadog Java agent, receiving the flag configuration from Remote Configuration. */ +class OpenFeatureProviderSmokeTest extends AbstractOpenFeatureSmokeTest { @Override ProcessBuilder createProcessBuilder() { - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) + setRemoteConfig("datadog/2/FFE_FLAGS/1/config", ufcPayload) final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") final command = [javaPath()] @@ -48,18 +30,13 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { } @Override - Closure decodedTracesCallback() { - return {} + boolean deliversExposures() { + return true } @Override - Closure decodedEvpProxyMessageCallback() { - return { String path, RequestApi request -> - if (!path.contains('api/v2/exposures')) { - return null - } - return new JsonSlurper().parse(request.body) - } + void publishConfiguration() { + setRemoteConfig("datadog/2/FFE_FLAGS/1/config", ufcPayload) } void 'test first remote config poll asks agent for feature flags'() { @@ -79,7 +56,7 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { void 'test open feature evaluation enriches the request span'() { setup: - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) + publishConfiguration() final request = new Request.Builder() .url("http://localhost:${httpPort}/openfeature/evaluate") .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson([ @@ -105,152 +82,6 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { response.close() } - void 'test open feature exposures'() { - setup: - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) - final url = "http://localhost:${httpPort}/openfeature/evaluate" - final testCases = parseTestCases() - assert !testCases.isEmpty() - - when: - final results = testCases.collect { - testCase -> - final request = new Request.Builder() - .url(url) - .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase))) - .build() - final response = client.newCall(request).execute() - final responseBody = new JsonSlurper().parse(response.body().byteStream()) - return [testCase: testCase, response: response, body: responseBody] - } - final expectedExposures = uniqueExpectedExposures(results) - - then: - results.every { - it.response.code() == 200 - } - !expectedExposures.isEmpty() - new PollingConditions(timeout: 10).eventually { - final requests = evpProxyMessages*.getV2() as List> - final events = requests*.exposures.flatten() - assert events.size() == expectedExposures.size() - expectedExposures.each { - expected -> - assert events.find { - event -> - event.flag.key == expected.flag && - event.allocation.key == expected.allocation && - event.variant.key == expected.variant && - event.subject.id == expected.targetingKey - } != null : "Unable to find exposure ${expected}" - } - } - } - - @Unroll("test open feature evaluation - #testCase.fileName[#testCase.index] - flag=#testCase.flag") - void 'test open feature evaluation'() { - setup: - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) - final url = "http://localhost:${httpPort}/openfeature/evaluate" - final request = new Request.Builder() - .url(url) - .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase))) - .build() - - when: - final response = client.newCall(request).execute() - - then: - response.code() == 200 - final responseBody = new JsonSlurper().parse(response.body().byteStream()) - responseBody.value == testCase.result.value - responseBody.reason == testCase.result.reason - if (testCase.result.containsKey('errorCode')) { - assert responseBody.errorCode == testCase.result.errorCode - } - if (testCase.result.containsKey('variant')) { - assert responseBody.variant == testCase.result.variant - } - if (testCase.result.flagMetadata?.allocationKey) { - assert responseBody.flagMetadata?.allocationKey == testCase.result.flagMetadata?.allocationKey - } - - where: - 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() - final testsPath = Paths.get(uri) - final files = Files.list(testsPath) - .filter(path -> path.toString().endsWith('.json')) - .sorted(Comparator.comparing(path -> path.fileName.toString())) - final result = [] - final slurper = new JsonSlurper() - files.each { - path -> - final testCases = slurper.parse(path.toFile()) as List> - testCases.eachWithIndex { - testCase, index -> - testCase.fileName = path.fileName.toString() - testCase.index = index - } - result.addAll(testCases) - } - assert !result.isEmpty() - return result - } - - private List> uniqueExpectedExposures(final List> results) { - final expected = [] - final seen = [] as Set - results.each { result -> - final testCase = result.testCase as Map - final body = result.body as Map - final flag = testCase.flag as String - final allocation = body.flagMetadata?.allocationKey as String - final variant = body.variant as String - if (!variant || !allocation || !allocationLogs(flag, allocation)) { - return - } - - final exposure = [ - flag: flag, - allocation: allocation, - variant: variant, - targetingKey: testCase.targetingKey - ] - final key = "${exposure.flag}\u0000${exposure.targetingKey}\u0000${exposure.allocation}\u0000${exposure.variant}" - if (seen.add(key)) { - expected.add(exposure) - } - } - return expected - } - - private boolean allocationLogs(final String flag, final String allocation) { - return loggedAllocations["${flag}\u0000${allocation}"] == true - } - - private static Map buildLoggedAllocations(final Map config) { - final logged = [:] - (config.flags as Map).each { flag, definition -> - if (definition.allocations instanceof List) { - definition.allocations.each { allocation -> - if (allocation instanceof Map) { - logged["${flag}\u0000${allocation.key}"] = allocation.doLog == true - } - } - } - } - return logged - } - private static Set decodeProducts(final Map request) { return request.client.products.collect { Product.valueOf(it) } } diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureStandaloneSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureStandaloneSmokeTest.groovy new file mode 100644 index 00000000000..86bbf6e41be --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureStandaloneSmokeTest.groovy @@ -0,0 +1,35 @@ +package datadog.smoketest.springboot + +/** + * The SDK without the Datadog Java agent: the SDK polls the flag configuration from the CDN. Without + * an API key, events are not delivered. + */ +class OpenFeatureStandaloneSmokeTest extends AbstractOpenFeatureSmokeTest { + + @Override + ProcessBuilder createProcessBuilder() { + final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") + final command = [javaPath()] + command.addAll(nativeJavaProperties) + command.addAll(['-jar', springBootShadowJar, "--server.port=${httpPort}".toString()]) + final builder = new ProcessBuilder(command).directory(new File(buildDirectory)) + builder.environment().put('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL', "http://localhost:${cdn.address.port}".toString()) + return builder + } + + @Override + boolean deliversExposures() { + return false + } + + @Override + boolean testTelemetry() { + return false + } + + /** Disables direct intake delivery, which would otherwise reach the real Datadog intake. */ + @Override + String apiKey() { + return '' + } +} From 8529eea380058ecf84cd26a7655c1d41886b5c76 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 21 Oct 2026 13:47:21 +0200 Subject: [PATCH 2/2] docs(ffe): Document the dd-openfeature SDK Describe the SDK setup, its configuration, the features by setup, the migration from earlier versions and the design notes for contributors. Co-Authored-By: Claude Opus 5.5 --- dd-openfeature/README.md | 200 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 dd-openfeature/README.md diff --git a/dd-openfeature/README.md b/dd-openfeature/README.md new file mode 100644 index 00000000000..b1381a2492b --- /dev/null +++ b/dd-openfeature/README.md @@ -0,0 +1,200 @@ +# Datadog OpenFeature provider + +`com.datadoghq:dd-openfeature` is the Datadog Feature Flags SDK for the [OpenFeature](https://openfeature.dev/) Java API. +It evaluates flags locally from the Universal Flag Configuration (UFC) and reports exposures and evaluation counts to Datadog. + +The SDK works on its own. When the Datadog Java agent (`dd-java-agent`) is attached, the SDK also connects to it to use Remote Configuration, the Datadog Agent event proxy, agent telemetry, and APM span enrichment. + +## Requirements + +* Java 11 or later +* A Datadog [API key](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys) and [site](https://docs.datadoghq.com/getting_started/site/), unless events go through a Datadog Agent +* Optionally, `dd-java-agent` 1.67.0 or later + +## Setup + +Add the dependency. It brings the OpenFeature SDK 1.23.0, the OpenTelemetry API and `jackson-core`: + +```xml + + com.datadoghq + dd-openfeature + ${dd-openfeature.version} + +``` + +Register the provider: + +```java +import com.datadog.openfeature.Provider; +import dev.openfeature.sdk.OpenFeatureAPI; + +OpenFeatureAPI.getInstance().setProviderAndWait(new Provider()); +Client client = OpenFeatureAPI.getInstance().getClient(); +``` + +`new Provider(new Provider.Options().initTimeout(10, SECONDS))` changes how long initialization waits for the first flag configuration (30 seconds by default). + +The provider sends exposures from an OpenFeature provider hook. If you wrap it in OpenFeature's `MultiProvider`, use `dev.openfeature:sdk` 1.23.0 or later: earlier versions do not run the hooks of the providers inside a `MultiProvider`, so the provider sends no exposures there. + +## Configuration + +Settings are read from the Datadog Java agent configuration when attached, then from `dd.`-prefixed system properties, then from environment variables. + +| Environment variable | Default | Description | +|---|---|---| +| `DD_FEATURE_FLAGS_ENABLED` | `true` | Enables Feature Flags. `false` makes the provider fail initialization, so evaluations return defaults. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE` | `agentless` | `agentless` polls the configuration from the Datadog CDN. `remote_config` receives it through Remote Configuration and requires `dd-java-agent`. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL` | | Custom configuration endpoint. A URL without a path gets the default path. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS` | `30` | CDN poll interval. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS` | `5` | CDN request timeout. | +| `DD_FLAGGING_EVALUATION_COUNTS_ENABLED` | `true` | Sends aggregated flag evaluation counts. | +| `DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED` | `true` | Sends exposures to Datadog. See [Stop sending exposures to Datadog](#stop-sending-exposures-to-datadog). | +| `DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED` | `false` | Lets the `dd_evaluation_telemetry_opt_out` context attribute opt subjects out of evaluation telemetry. See [Per-subject evaluation telemetry opt-out](#per-subject-evaluation-telemetry-opt-out). | +| `DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED` | `false` | Adds flag evaluations to the local root span. Requires `dd-java-agent`. | +| `DD_API_KEY`, `DD_SITE`, `DD_ENV`, `DD_SERVICE`, `DD_VERSION` | | Credentials and event context. | +| `DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED` | | Deprecated: `true` selects `remote_config`, `false` disables Feature Flags. An explicit source wins. | + +## Features by setup + +| Feature | SDK only | SDK + `dd-java-agent` | +|---|---|---| +| Configuration, `agentless` source | CDN | CDN | +| Configuration, `remote_config` source | Not supported | Remote Configuration | +| Exposures and evaluation counts | Direct intake, requires `DD_API_KEY` | Datadog Agent event proxy, with direct intake fallback in `agentless` mode | +| `feature_flag.evaluations` OpenTelemetry metric | Yes | Yes | +| Health metrics | No | Agent telemetry | +| Span enrichment | No | Yes | + +## Exposures in your own data store + +The provider sends exposures to Datadog. It also puts its exposure decision and the selected split's features into the flag metadata of every evaluation, so any OpenFeature hook can record exposures in your own data store (see [Flag metadata](#flag-metadata)). + +For convenience, `dd-openfeature` ships `ExposureHook`, which reads that metadata for you. It receives every completed evaluation, with the same exposure decision that the provider uses for Datadog. + +```java +import com.datadog.openfeature.ExposureHook; + +BlockingQueue queue = new LinkedBlockingQueue<>(10_000); + +OpenFeatureAPI.getInstance().addHooks(new ExposureHook(evaluation -> { + if (!evaluation.shouldSend()) { + return; + } + Map features = evaluation.getFeatures(); + queue.offer(new ExposureRecord( + evaluation.getDetails().getFlagKey(), + evaluation.getDetails().getVariant(), + evaluation.getContext().getTargetingKey(), + (String) features.get("holdout.key"), + (String) features.get("holdout.assignment_group"))); +})); +// A background thread drains the queue and writes to the data store. +``` + +* The callback runs on the evaluation thread. Do not write to the data store from the callback. Copy what you need and hand it to a background queue. +* `shouldSend()` is true for a successful evaluation of an allocation that logs exposures, for a subject not yet exposed to the same allocation, variant and serial id. These are exactly the exposures that the provider sends to Datadog, as long as Datadog logging is on and the provider's own hooks run: evaluate through an OpenFeature client, and inside a `MultiProvider` use OpenFeature SDK 1.23.0 or later. With an older SDK, a provider inside a `MultiProvider` sends no exposures, and `shouldSend()` is true for every exposure. +* `isExposure()` is true for every successful evaluation of an allocation that logs exposures, including repeats. `isCacheHit()` tells repeats apart. +* An evaluation that fails, including one that a later `after` hook fails, is not an exposure. +* An exception from the callback does not affect the evaluation. The first one is logged at warn level, later ones at debug level. + +### Features + +`getFeatures()` returns the features of the selected split whose destinations include `HOOK`. Values are a `String`, `Double` or `Boolean`. Holdout splits carry: + +| Feature | Value | +|---|---| +| `holdout.key` | The holdout key | +| `holdout.assignment_group` | `status_quo` or `all_shipped` | + +The map is empty when the split has no such features. Datadog can add destinations later; the provider ignores a destination it does not know and logs a warning once for each one. + +### Flag metadata + +The same data is in the evaluation's flag metadata, for hooks that use only the OpenFeature API. Read it in the hook's `finallyAfter` stage, where the details match what the application receives, and skip details that have an error code. `ExposureHook` does both. + +| Key | Type | Meaning | +|---|---|---| +| `allocationKey` | String | The selected allocation | +| `__dd_do_log` | Boolean | Whether the allocation logs exposures. Present when the allocation logs exposures or span enrichment is on | +| `__dd_exposure_cache_hit` | Boolean | The subject was already exposed. Present only when `__dd_do_log` is true | +| `__dd_split_serial_id` | Integer | The split's serial id. Present when `__dd_do_log` is present and the split has a serial id | +| `__dd_feature.` | String, Double or Boolean | One entry for each feature of the selected split that lists the `HOOK` destination | + +### Stop sending exposures to Datadog + +```shell +DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED=false +``` + +The provider then sends no exposures to Datadog. `ExposureHook` callbacks still receive every evaluation, and `shouldSend()` gives the same result as before. The default is `true`. + +This setting stops exposures only. Flag evaluation events have their own setting, `DD_FLAGGING_EVALUATION_COUNTS_ENABLED`. The Java provider does not read dd-trace-py's `DD_FFE_INTAKE_ENABLED`. + +## Per-subject evaluation telemetry opt-out + +Applications can keep flag evaluation enabled while excluding selected subjects from evaluation +telemetry. Configure the provider once, then pass the decision with each evaluation: + +```java +import com.datadog.openfeature.Provider; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Value; +import java.util.Collections; + +OpenFeatureAPI api = OpenFeatureAPI.getInstance(); +api.setProviderAndWait(new Provider( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(true))); +Client client = api.getClient(); + +// In a request handler, the application owns its consent and eligibility rules. +boolean optedOut = user.hasDeclinedEvaluationTelemetry(); +boolean enabled = client.getBooleanValue("my-feature", false, + new ImmutableContext(user.getId(), Collections.singletonMap( + "dd_evaluation_telemetry_opt_out", new Value(optedOut)))); +``` + +Use the per-evaluation context for concurrent requests. Do not replace the global context for each +subject. The key is exactly `dd_evaluation_telemetry_opt_out`, as in other Datadog SDKs. + +Alternatively, enable support through deployment configuration and use `new Provider()`: + +```shell +DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED=true +``` + +The equivalent JVM property is `-Ddd.feature.flags.evaluation.telemetry.per.subject.opt.out.enabled=true`. +The provider reads the normal Datadog configuration sources at construction. The default is `false`. +An explicit provider option overrides these sources, including an explicit `false`. +This setting only enables support for the subject attribute. It does not suppress telemetry for all subjects. + +Only boolean `true` suppresses telemetry. Missing, false, null, and other types preserve telemetry. +The provider suppresses telemetry only when the option is enabled before provider construction. + +The attribute remains available for targeting and exposure reporting with either activation setting. +Existing privacy and reporting settings apply. Targeting rules can use the attribute to select +assignments. Normal exposure deduplication applies when its value changes. + +Opt-out suppresses new evaluation EVP events and counts, OpenTelemetry evaluation metrics, and +automatic flag-related span enrichment. It does not change flag values, evaluation details, +application defaults, experiment assignment, or exposure logging. Application hooks and unrelated +tracing remain active. Previously recorded or queued data is not removed. + +## Migrating from earlier versions + +* The provider moved to `com.datadog.openfeature.Provider`, and the exposure hook to `com.datadog.openfeature.ExposureHook`. `datadog.trace.api.openfeature.Provider` and `datadog.trace.api.openfeature.ExposureHook` are kept as deprecated aliases. +* The SDK no longer requires `dd-java-agent` and no longer links against agent classes. +* `dd-openfeature` 1.66.0 and earlier require the agent classes this release removes. Upgrade `dd-openfeature` together with `dd-java-agent` 1.67.0 or later. + +## Design notes for contributors + +The SDK does not use any dd-trace-java internal module. Its parts that the agent can provide are behind the `com.datadog.openfeature.internal.bridge.v1` SPI: + +* `AgentBridges.detect()` returns `AgentBridge.NONE` without the agent. +* The `:dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-1.69` instrumentation replaces its result with a bridge delegating to the agent backend (`products/feature-flagging/feature-flagging-lib`) through the bootstrap `FeatureFlaggingGateway`. +* The `:dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-1.56` instrumentation keeps the providers released before the bridge (1.56 to 1.68) from linking to the agent bootstrap API they used: they return the caller defaults. +* The SPI is the only contract crossing SDK and agent versions, and the instrumentation references to it are checked by muzzle. Once released, an SPI version is frozen: only `default` methods can be added. A breaking change goes to a new package (`bridge.v2`), with its own agent instrumentation, and the SDK keeps the previous version for the agents that only implement it. The `japicmpBridge` tasks check each released SPI version against the first SDK release that shipped it. +* UFC parsing, evaluation, event payloads and batching stay in the SDK. The agent forwards raw Remote Configuration documents and event payloads. +* The public API is everything outside the `internal` packages. The `japicmpPublicApi` task checks it stays binary and source compatible with the previous release, the latest one before the version being built, so each release keeps the API of all the previous ones. Exclude an intentional break explicitly.