Skip to content

Commit 76917d6

Browse files
committed
Support the UFC CDN response contract
1 parent eaaea27 commit 76917d6

4 files changed

Lines changed: 120 additions & 30 deletions

File tree

products/feature-flagging/feature-flagging-api/README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,16 @@ OTEL_EXPORTER_OTLP_PROTOCOL=grpc
8888
- `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless` uses the Datadog agentless
8989
backend. Set `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL` to a
9090
different HTTP backend while keeping agentless delivery semantics. A bare
91-
host uses the standard server-distribution path; a URL with a path is used as
91+
host uses the standard rules-based server path; a URL with a path is used as
9292
the exact UFC endpoint. Configured URLs are opaque: the SDK does not add the
9393
Datadog-managed `dd_env` query parameter, so custom backends must include any
9494
required tenant or environment scope in the configured URL. The derived
95-
Datadog-managed endpoint is intended for supported commercial sites; use an
96-
explicit base URL elsewhere. Agentless responses do not have an SDK-imposed
97-
payload-size limit. `remote_config` uses the existing Agent Remote
95+
Datadog-managed endpoint is
96+
`https://ufc-server.ff-cdn.<site>/api/v2/feature-flagging/config/rules-based/server`
97+
and expects UFC under the JSON:API `data.attributes` response member. It is
98+
intended for supported commercial sites; use an explicit base URL elsewhere.
99+
Agentless responses do not have an SDK-imposed payload-size limit.
100+
`remote_config` uses the existing Agent Remote
98101
Configuration path. `offline` is reserved for startup-provided UFC bytes;
99102
until those bytes are implemented, no network source starts and evaluations
100103
use defaults.

products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,8 @@
3535
final class AgentlessConfigurationSource implements ConfigurationSourceService {
3636
private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessConfigurationSource.class);
3737

38-
// TODO before merge: confirm the final backend route with the server-distribution API owners.
39-
private static final String DATADOG_API_SERVER_DISTRIBUTION_PATH =
40-
"/api/v2/feature-flagging/config/server-distribution";
38+
private static final String DATADOG_UFC_RULES_BASED_SERVER_PATH =
39+
"/api/v2/feature-flagging/config/rules-based/server";
4140
private static final int MAX_ATTEMPTS = 3;
4241
private static final int MINUTES_BETWEEN_WARNINGS = 5;
4342
private static final long FIRST_RETRY_MIN_MILLIS = 2_000;
@@ -255,8 +254,9 @@ private boolean apply(final UfcHttpResponse response) {
255254
final ServerConfiguration configuration;
256255
try {
257256
configuration =
258-
RemoteConfigServiceImpl.UniversalFlagConfigDeserializer.INSTANCE.deserialize(
259-
response.body);
257+
RemoteConfigServiceImpl.UniversalFlagConfigDeserializer.INSTANCE.deserializeApiResponse(
258+
response.body,
259+
config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl() != null);
260260
} catch (final IOException | RuntimeException e) {
261261
LOGGER.debug("Feature Flagging HTTP configuration source returned malformed UFC payload", e);
262262
return false;
@@ -295,7 +295,7 @@ private static HttpUrl endpointFromConfiguredBaseUrl(final String configuredBase
295295
if ("/".equals(parsed.encodedPath()) || parsed.encodedPath().isEmpty()) {
296296
return parsed
297297
.newBuilder()
298-
.addPathSegments(DATADOG_API_SERVER_DISTRIBUTION_PATH.substring(1))
298+
.addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1))
299299
.build();
300300
}
301301
return parsed;
@@ -305,8 +305,8 @@ private static HttpUrl datadogApiServerDistributionEndpoint(final Config config)
305305
final HttpUrl.Builder endpoint =
306306
new HttpUrl.Builder()
307307
.scheme("https")
308-
.host("api." + config.getSite())
309-
.addPathSegments(DATADOG_API_SERVER_DISTRIBUTION_PATH.substring(1));
308+
.host("ufc-server.ff-cdn." + config.getSite())
309+
.addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1));
310310
final String env = config.getEnv();
311311
if (env != null && !env.isEmpty()) {
312312
endpoint.addQueryParameter("dd_env", env);

products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,47 @@ public void accept(
6666
static class UniversalFlagConfigDeserializer
6767
implements ConfigurationDeserializer<ServerConfiguration> {
6868

69+
private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration";
6970
static final UniversalFlagConfigDeserializer INSTANCE = new UniversalFlagConfigDeserializer();
7071

7172
private static final Moshi MOSHI =
7273
new Moshi.Builder().add(Date.class, new DateAdapter()).add(FlagMapAdapter.FACTORY).build();
7374
private static final JsonAdapter<ServerConfiguration> V1_ADAPTER =
7475
MOSHI.adapter(ServerConfiguration.class);
76+
private static final Type API_RESPONSE_TYPE =
77+
Types.newParameterizedType(Map.class, String.class, Object.class);
78+
private static final JsonAdapter<Map<String, Object>> API_RESPONSE_ADAPTER =
79+
MOSHI.adapter(API_RESPONSE_TYPE);
7580

7681
@Override
7782
public ServerConfiguration deserialize(final byte[] content) throws IOException {
7883
return V1_ADAPTER.fromJson(Okio.buffer(Okio.source(new ByteArrayInputStream(content))));
7984
}
85+
86+
@Nullable
87+
ServerConfiguration deserializeApiResponse(
88+
final byte[] content, final boolean allowRawConfiguration) throws IOException {
89+
final Map<String, Object> response =
90+
API_RESPONSE_ADAPTER.fromJson(
91+
Okio.buffer(Okio.source(new ByteArrayInputStream(content))));
92+
if (response != null && response.containsKey("data")) {
93+
final Object data = response.get("data");
94+
if (!(data instanceof Map)) {
95+
return null;
96+
}
97+
final Map<?, ?> dataAttributes = (Map<?, ?>) data;
98+
return UNIVERSAL_FLAG_CONFIGURATION_TYPE.equals(dataAttributes.get("type"))
99+
? validConfiguration(V1_ADAPTER.fromJsonValue(dataAttributes.get("attributes")))
100+
: null;
101+
}
102+
return allowRawConfiguration ? validConfiguration(deserialize(content)) : null;
103+
}
104+
105+
@Nullable
106+
private static ServerConfiguration validConfiguration(
107+
@Nullable final ServerConfiguration configuration) {
108+
return configuration != null && configuration.flags != null ? configuration : null;
109+
}
80110
}
81111

82112
static class FlagMapAdapter extends JsonAdapter<Map<String, Flag>> {

products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151

5252
@ExtendWith(MockitoExtension.class)
5353
class AgentlessConfigurationSourceTest {
54-
private static final String CONFIG_PATH = "/api/v2/feature-flagging/config/server-distribution";
54+
private static final String CONFIG_PATH = "/api/v2/feature-flagging/config/rules-based/server";
5555

5656
@Mock private FeatureFlaggingGateway.ConfigListener listener;
5757

@@ -62,33 +62,33 @@ void cleanup() {
6262
}
6363

6464
@Test
65-
void derivesDatadogApiServerDistributionEndpointFromSiteAndEnv() {
65+
void derivesDatadogUfcCdnEndpointFromSiteAndEnv() {
6666
final Config config = config("datad0g.com", "staging env");
6767

6868
assertEquals(
69-
"https://api.datad0g.com/api/v2/feature-flagging/config/server-distribution?dd_env=staging%20env",
69+
"https://ufc-server.ff-cdn.datad0g.com/api/v2/feature-flagging/config/rules-based/server?dd_env=staging%20env",
7070
AgentlessConfigurationSource.endpoint(config).toString());
7171
}
7272

7373
@Test
74-
void derivesDatadogApiServerDistributionEndpointWithoutEnv() {
74+
void derivesDatadogUfcCdnEndpointWithoutEnv() {
7575
assertEquals(
76-
"https://api.datadoghq.com/api/v2/feature-flagging/config/server-distribution",
76+
"https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server",
7777
AgentlessConfigurationSource.endpoint(config("datadoghq.com", "")).toString());
7878
assertEquals(
79-
"https://api.datadoghq.com/api/v2/feature-flagging/config/server-distribution",
79+
"https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server",
8080
AgentlessConfigurationSource.endpoint(config("datadoghq.com", null)).toString());
8181
}
8282

8383
@Test
84-
void appendsServerDistributionPathToConfiguredAgentlessBaseUrl() {
84+
void appendsRulesBasedServerPathToConfiguredAgentlessBaseUrl() {
8585
final Config config = config();
8686
lenient()
8787
.when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl())
8888
.thenReturn("http://mock-backend:8080");
8989

9090
assertEquals(
91-
"http://mock-backend:8080/api/v2/feature-flagging/config/server-distribution",
91+
"http://mock-backend:8080/api/v2/feature-flagging/config/rules-based/server",
9292
AgentlessConfigurationSource.endpoint(config).toString());
9393
}
9494

@@ -116,7 +116,7 @@ void rejectsInvalidConfiguredAgentlessBaseUrl() {
116116
}
117117

118118
@Test
119-
void rejectsInvalidDatadogApiServerDistributionEndpoint() {
119+
void rejectsInvalidDatadogUfcCdnEndpoint() {
120120
assertThrows(
121121
IllegalArgumentException.class,
122122
() -> AgentlessConfigurationSource.endpoint(config("datadoghq.com:bad", "")));
@@ -359,18 +359,43 @@ void realHttpClientTimesOutDelayedResponse() throws Exception {
359359
}
360360

361361
@Test
362-
void appliesAcceptedUfcThroughGatewayAndSendsApiKey() throws Exception {
362+
void appliesAcceptedJsonApiUfcThroughGatewayAndSendsApiKey() throws Exception {
363363
final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig()));
364364
final AgentlessConfigurationSource service = service(client);
365+
final ArgumentCaptor<ServerConfiguration> configuration =
366+
ArgumentCaptor.forClass(ServerConfiguration.class);
365367
FeatureFlaggingGateway.addConfigListener(listener);
366368

367369
assertTrue(service.pollOnce());
368370

369-
verify(listener).accept(any(ServerConfiguration.class));
371+
verify(listener).accept(configuration.capture());
372+
assertEquals("2026-07-15T19:57:07.219869778Z", configuration.getValue().createdAt);
373+
assertNull(configuration.getValue().format);
374+
assertEquals("Staging", configuration.getValue().environment.name);
375+
assertTrue(configuration.getValue().flags.isEmpty());
370376
assertEquals("test-api-key", client.requests.get(0).apiKey);
371377
assertNull(client.requests.get(0).etag);
372378
}
373379

380+
@Test
381+
void appliesRawUfcFromConfiguredCompatibilityEndpoint() throws Exception {
382+
final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfigAttributes()));
383+
final Config config = config();
384+
lenient()
385+
.when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl())
386+
.thenReturn("http://compatibility-backend/custom/ufc");
387+
final AgentlessConfigurationSource service = service(client, config);
388+
final ArgumentCaptor<ServerConfiguration> configuration =
389+
ArgumentCaptor.forClass(ServerConfiguration.class);
390+
FeatureFlaggingGateway.addConfigListener(listener);
391+
392+
assertTrue(service.pollOnce());
393+
394+
verify(listener).accept(configuration.capture());
395+
assertEquals("Staging", configuration.getValue().environment.name);
396+
assertTrue(configuration.getValue().flags.isEmpty());
397+
}
398+
374399
@Test
375400
void ignoresBlankEtag() throws Exception {
376401
final FakeClient client =
@@ -486,10 +511,19 @@ void rejectsForbiddenNonOkMissingBodyAndNullConfiguration() throws Exception {
486511
response(404, null, null),
487512
response(600, null, null),
488513
response(200, null, null),
489-
response(200, null, "null"));
514+
response(200, null, "null"),
515+
response(200, null, jsonApiResponse("other-configuration", emptyConfigAttributes())),
516+
response(200, null, "{\"data\":null}"),
517+
response(
518+
200, null, "{\"data\":{\"id\":\"1\",\"type\":\"universal-flag-configuration\"}}"),
519+
response(200, null, emptyConfigAttributes()));
490520
final AgentlessConfigurationSource service = service(client);
491521
FeatureFlaggingGateway.addConfigListener(listener);
492522

523+
assertFalse(service.pollOnce());
524+
assertFalse(service.pollOnce());
525+
assertFalse(service.pollOnce());
526+
assertFalse(service.pollOnce());
493527
assertFalse(service.pollOnce());
494528
assertFalse(service.pollOnce());
495529
assertFalse(service.pollOnce());
@@ -902,6 +936,16 @@ private static AgentlessConfigurationSource service(final FakeClient client) {
902936
return service(client, delay -> {}, () -> 1.0);
903937
}
904938

939+
private static AgentlessConfigurationSource service(
940+
final FakeClient client, final Config config) {
941+
return new AgentlessConfigurationSource(
942+
HttpUrl.get("http://localhost" + CONFIG_PATH),
943+
config,
944+
30_000,
945+
client,
946+
Executors.newSingleThreadScheduledExecutor());
947+
}
948+
905949
private static AgentlessConfigurationSource service(
906950
final FakeClient client,
907951
final AgentlessConfigurationSource.RetrySleeper retrySleeper,
@@ -943,19 +987,32 @@ private static AgentlessConfigurationSource.UfcHttpResponse response(
943987
}
944988

945989
private static String emptyConfig() {
990+
return jsonApiResponse("universal-flag-configuration", emptyConfigAttributes());
991+
}
992+
993+
private static String emptyConfigAttributes() {
946994
return "{"
947-
+ "\"createdAt\":\"2024-04-17T19:40:53.716Z\","
948-
+ "\"format\":\"SERVER\","
949-
+ "\"environment\":{\"name\":\"Test\"},"
995+
+ "\"createdAt\":\"2026-07-15T19:57:07.219869778Z\","
996+
+ "\"environment\":{\"name\":\"Staging\"},"
950997
+ "\"flags\":{}"
951998
+ "}";
952999
}
9531000

1001+
private static String jsonApiResponse(final String type, final String attributes) {
1002+
return "{\"data\":{"
1003+
+ "\"id\":\"1\","
1004+
+ "\"type\":\""
1005+
+ type
1006+
+ "\","
1007+
+ "\"attributes\":"
1008+
+ attributes
1009+
+ "}}";
1010+
}
1011+
9541012
private static String largeConfig(final int flagCount) {
9551013
final StringBuilder json =
9561014
new StringBuilder(
957-
"{\"createdAt\":\"2024-04-17T19:40:53.716Z\","
958-
+ "\"format\":\"SERVER\","
1015+
"{\"createdAt\":\"2026-07-15T19:57:07.219869778Z\","
9591016
+ "\"environment\":{\"name\":\"Large Test\"},"
9601017
+ "\"flags\":{");
9611018
for (int index = 0; index < flagCount; index++) {
@@ -972,7 +1029,7 @@ private static String largeConfig(final int flagCount) {
9721029
+ "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"on\"}},"
9731030
+ "\"allocations\":[]}");
9741031
}
975-
return json.append("}}").toString();
1032+
return jsonApiResponse("universal-flag-configuration", json.append("}}").toString());
9761033
}
9771034

9781035
private static void awaitCalls(final FakeClient client, final int count) throws Exception {

0 commit comments

Comments
 (0)