Skip to content

Commit 239cf99

Browse files
PerfectSlayerclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 7b28bc5 commit 239cf99

5 files changed

Lines changed: 290 additions & 181 deletions

File tree

‎ARCHITECTURE.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ to `.classdata` to prevent unintended loading. See [`docs/how_to_work_with_gradl
6464

6565
- **`agent-tooling/`** — Instrumentation framework. Key types:
6666
- `InstrumenterModule` — Base class for all instrumentation modules. Declares a target system
67-
(Tracing, AppSec, IAST, Profiling, CiVisibility, USM, etc.) and one or more instrumentations.
67+
(Tracing, AppSec, IAST, Profiling, CiVisibility, USM, FeatureFlags, etc.) and one or more
68+
instrumentations.
6869
- `Instrumenter` — Type matching interface: `ForSingleType`, `ForKnownTypes`,
6970
`ForTypeHierarchy`, `ForBootstrap`.
7071
- `muzzle/` — Build-time and runtime safety checks. Verifies that expected types and methods
@@ -229,9 +230,8 @@ Current products:
229230

230231
- `metrics/` — StatsD client and monitoring abstraction. Provides `Monitoring` interface with
231232
counters, timers, and histograms for internal agent metrics collection.
232-
- `feature-flagging/` — Server-side feature flag evaluation driven by remote configuration.
233-
Implements the OpenFeature SDK, handles the Unified Feature Control (UFC) protocol,
234-
and tracks flag exposure per user/session.
233+
- `feature-flagging/` — Agent services for the [`dd-openfeature/`](#dd-openfeature) SDK:
234+
remote configuration, event delivery through the Datadog Agent, and span enrichment.
235235

236236
### `communication/`
237237

@@ -279,6 +279,12 @@ that implements the `io.opentracing.Tracer` interface by wrapping the Datadog `C
279279
This is a pure library for manual instrumentation only — there is no auto-instrumentation or
280280
bytecode advice.
281281

282+
### `dd-openfeature/`
283+
284+
Feature Flags OpenFeature provider. Publishes a standalone JAR artifact (`dd-openfeature.jar`, Java 11+)
285+
that works without the agent. When the agent is attached, an instrumentation connects it to the
286+
`products/feature-flagging/` agent services.
287+
282288
### `dd-smoke-tests/`
283289

284290
End-to-end smoke tests. Each boots a real application with the agent jar and verifies traces, spans,
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package datadog.smoketest.springboot
2+
3+
import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer
4+
5+
import datadog.smoketest.AbstractServerSmokeTest
6+
import datadog.trace.agent.test.server.http.TestHttpServer
7+
import datadog.trace.agent.test.server.http.TestHttpServer.HandlerApi.RequestApi
8+
import groovy.json.JsonOutput
9+
import groovy.json.JsonSlurper
10+
import java.nio.file.Files
11+
import java.nio.file.Paths
12+
import okhttp3.MediaType
13+
import okhttp3.Request
14+
import okhttp3.RequestBody
15+
import spock.lang.AutoCleanup
16+
import spock.lang.IgnoreIf
17+
import spock.lang.Shared
18+
import spock.lang.Stepwise
19+
import spock.lang.Unroll
20+
import spock.util.concurrent.PollingConditions
21+
22+
/** Due to the exposure cache it's important to run the tests in the specified order */
23+
@Stepwise
24+
abstract class AbstractOpenFeatureSmokeTest extends AbstractServerSmokeTest {
25+
26+
@Shared
27+
protected final ufcConfig = new JsonSlurper().parse(fetchResource("ffe-system-test-data/ufc-config.json")) as Map<String, Object>
28+
29+
@Shared
30+
protected final ufcPayload = JsonOutput.toJson(ufcConfig)
31+
32+
@Shared
33+
protected final loggedAllocations = buildLoggedAllocations(ufcConfig)
34+
35+
/** Serves the flag configuration as the Datadog managed CDN does. */
36+
@Shared
37+
@AutoCleanup
38+
protected TestHttpServer cdn = httpServer {
39+
handlers {
40+
prefix("/api/v2/feature-flagging/config/rules-based/server") {
41+
response.status(200).send(JsonOutput.toJson([
42+
data: [type: 'universal-flag-configuration', attributes: ufcConfig]
43+
]))
44+
}
45+
}
46+
}
47+
48+
/** @return whether exposures are delivered through the Datadog Agent event platform proxy. */
49+
abstract boolean deliversExposures()
50+
51+
/** Publishes the flag configuration before each evaluation. */
52+
void publishConfiguration() {}
53+
54+
@Override
55+
Closure decodedTracesCallback() {
56+
return {}
57+
}
58+
59+
@Override
60+
Closure decodedEvpProxyMessageCallback() {
61+
return { String path, RequestApi request ->
62+
if (!path.contains('api/v2/exposures')) {
63+
return null
64+
}
65+
return new JsonSlurper().parse(request.body)
66+
}
67+
}
68+
69+
@IgnoreIf({ !instance.deliversExposures() })
70+
void 'test open feature exposures'() {
71+
setup:
72+
publishConfiguration()
73+
final testCases = parseTestCases()
74+
assert !testCases.isEmpty()
75+
76+
when:
77+
final results = testCases.collect {
78+
testCase ->
79+
final response = evaluate(testCase)
80+
final responseBody = new JsonSlurper().parse(response.body().byteStream())
81+
return [testCase: testCase, response: response, body: responseBody]
82+
}
83+
final expectedExposures = uniqueExpectedExposures(results)
84+
85+
then:
86+
results.every {
87+
it.response.code() == 200
88+
}
89+
!expectedExposures.isEmpty()
90+
new PollingConditions(timeout: 10).eventually {
91+
final requests = evpProxyMessages*.getV2() as List<Map<String, Object>>
92+
final events = requests*.exposures.flatten()
93+
assert events.size() == expectedExposures.size()
94+
expectedExposures.each {
95+
expected ->
96+
assert events.find {
97+
event ->
98+
event.flag.key == expected.flag &&
99+
event.allocation.key == expected.allocation &&
100+
event.variant.key == expected.variant &&
101+
event.subject.id == expected.targetingKey
102+
} != null : "Unable to find exposure ${expected}"
103+
}
104+
}
105+
}
106+
107+
@Unroll("test open feature evaluation - #testCase.fileName[#testCase.index] - flag=#testCase.flag")
108+
void 'test open feature evaluation'() {
109+
setup:
110+
publishConfiguration()
111+
112+
when:
113+
final response = evaluate(testCase)
114+
115+
then:
116+
response.code() == 200
117+
final responseBody = new JsonSlurper().parse(response.body().byteStream())
118+
responseBody.value == testCase.result.value
119+
responseBody.reason == testCase.result.reason
120+
if (testCase.result.containsKey('errorCode')) {
121+
assert responseBody.errorCode == testCase.result.errorCode
122+
}
123+
if (testCase.result.containsKey('variant')) {
124+
assert responseBody.variant == testCase.result.variant
125+
}
126+
if (testCase.result.flagMetadata?.allocationKey) {
127+
assert responseBody.flagMetadata?.allocationKey == testCase.result.flagMetadata?.allocationKey
128+
}
129+
130+
where:
131+
testCase << parseTestCases()
132+
}
133+
134+
protected okhttp3.Response evaluate(final Map<String, Object> testCase) {
135+
final request = new Request.Builder()
136+
.url("http://localhost:${httpPort}/openfeature/evaluate")
137+
.post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase)))
138+
.build()
139+
return client.newCall(request).execute()
140+
}
141+
142+
protected static URL fetchResource(final String name) {
143+
return Thread.currentThread().getContextClassLoader().getResource(name)
144+
}
145+
146+
protected static List<Map<String, Object>> parseTestCases() {
147+
final folder = fetchResource('ffe-system-test-data/evaluation-cases')
148+
final uri = folder.toURI()
149+
final testsPath = Paths.get(uri)
150+
final files = Files.list(testsPath)
151+
.filter(path -> path.toString().endsWith('.json'))
152+
.sorted(Comparator.comparing(path -> path.fileName.toString()))
153+
final result = []
154+
final slurper = new JsonSlurper()
155+
files.each {
156+
path ->
157+
final testCases = slurper.parse(path.toFile()) as List<Map<String, Object>>
158+
testCases.eachWithIndex {
159+
testCase, index ->
160+
testCase.fileName = path.fileName.toString()
161+
testCase.index = index
162+
}
163+
result.addAll(testCases)
164+
}
165+
assert !result.isEmpty()
166+
return result
167+
}
168+
169+
protected List<Map<String, String>> uniqueExpectedExposures(final List<Map<String, Object>> results) {
170+
final expected = []
171+
final seen = [] as Set<String>
172+
results.each { result ->
173+
final testCase = result.testCase as Map<String, Object>
174+
final body = result.body as Map<String, Object>
175+
final flag = testCase.flag as String
176+
final allocation = body.flagMetadata?.allocationKey as String
177+
final variant = body.variant as String
178+
if (!variant || !allocation || !allocationLogs(flag, allocation)) {
179+
return
180+
}
181+
182+
final exposure = [
183+
flag: flag,
184+
allocation: allocation,
185+
variant: variant,
186+
targetingKey: testCase.targetingKey
187+
]
188+
final key = "${exposure.flag}\u0000${exposure.targetingKey}\u0000${exposure.allocation}\u0000${exposure.variant}"
189+
if (seen.add(key)) {
190+
expected.add(exposure)
191+
}
192+
}
193+
return expected
194+
}
195+
196+
protected boolean allocationLogs(final String flag, final String allocation) {
197+
return loggedAllocations["${flag}\u0000${allocation}"] == true
198+
}
199+
200+
protected static Map<String, Boolean> buildLoggedAllocations(final Map<String, Object> config) {
201+
final logged = [:]
202+
(config.flags as Map<String, Object>).each { flag, definition ->
203+
if (definition.allocations instanceof List) {
204+
definition.allocations.each { allocation ->
205+
if (allocation instanceof Map) {
206+
logged["${flag}\u0000${allocation.key}"] = allocation.doLog == true
207+
}
208+
}
209+
}
210+
}
211+
return logged
212+
}
213+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package datadog.smoketest.springboot
2+
3+
/**
4+
* The SDK with the Datadog Java agent and the default agentless configuration source: the SDK
5+
* polls the flag configuration from the CDN and delivers exposures through the agent.
6+
*/
7+
class OpenFeatureAgentlessSmokeTest extends AbstractOpenFeatureSmokeTest {
8+
9+
@Override
10+
ProcessBuilder createProcessBuilder() {
11+
final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path")
12+
final command = [javaPath()]
13+
command.addAll(defaultJavaProperties)
14+
command.addAll(['-jar', springBootShadowJar, "--server.port=${httpPort}".toString()])
15+
final builder = new ProcessBuilder(command).directory(new File(buildDirectory))
16+
builder.environment().put('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL', "http://localhost:${cdn.address.port}".toString())
17+
return builder
18+
}
19+
20+
@Override
21+
boolean deliversExposures() {
22+
return true
23+
}
24+
}

0 commit comments

Comments
 (0)