From 7772a57349d97b258b6cf9428d8a6289e3fcb3b5 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 12:03:51 -0400 Subject: [PATCH 1/4] Skip malformed FFE flags during config parsing --- .../feature-flagging-lib/build.gradle.kts | 2 + .../featureflag/RemoteConfigServiceImpl.java | 81 +++++- .../RemoteConfigServiceTest.groovy | 130 ---------- .../RemoteConfigServiceImplTest.java | 238 ++++++++++++++++++ 4 files changed, 312 insertions(+), 139 deletions(-) delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/groovy/com/datadog/featureflag/RemoteConfigServiceTest.groovy create mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 5381a9708ac..2888ba1b25c 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -25,6 +25,8 @@ dependencies { compileOnly(project(":dd-trace-core")) // shading does not work with this one + testImplementation(libs.bundles.junit5) + testImplementation(libs.bundles.mockito) testImplementation(project(":utils:test-utils")) testImplementation(project(":dd-java-agent:testing")) } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index cf0c400ccad..ec28f684a96 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java @@ -1,9 +1,11 @@ package com.datadog.featureflag; import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; import com.squareup.moshi.JsonReader; import com.squareup.moshi.JsonWriter; import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.remoteconfig.Capabilities; import datadog.remoteconfig.ConfigurationChangesTypedListener; @@ -13,11 +15,18 @@ import datadog.remoteconfig.Product; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.time.OffsetDateTime; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.time.Instant; +import java.time.format.DateTimeFormatter; import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; import javax.annotation.Nonnull; import javax.annotation.Nullable; import okio.Okio; @@ -59,11 +68,10 @@ static class UniversalFlagConfigDeserializer static final UniversalFlagConfigDeserializer INSTANCE = new UniversalFlagConfigDeserializer(); + private static final Moshi MOSHI = + new Moshi.Builder().add(Date.class, new DateAdapter()).add(FlagMapAdapter.FACTORY).build(); private static final JsonAdapter V1_ADAPTER = - new Moshi.Builder() - .add(Date.class, new DateAdapter()) - .build() - .adapter(ServerConfiguration.class); + MOSHI.adapter(ServerConfiguration.class); @Override public ServerConfiguration deserialize(final byte[] content) throws IOException { @@ -71,6 +79,63 @@ public ServerConfiguration deserialize(final byte[] content) throws IOException } } + static class FlagMapAdapter extends JsonAdapter> { + + private static final Type FLAGS_TYPE = + Types.newParameterizedType(Map.class, String.class, Flag.class); + + static final Factory FACTORY = + new Factory() { + @Nullable + @Override + public JsonAdapter create( + @Nonnull final Type type, + @Nonnull final Set annotations, + @Nonnull final Moshi moshi) { + if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { + return null; + } + return new FlagMapAdapter(moshi.adapter(Flag.class)); + } + }; + + private final JsonAdapter flagAdapter; + + FlagMapAdapter(final JsonAdapter flagAdapter) { + this.flagAdapter = flagAdapter; + } + + @Nullable + @Override + public Map fromJson(@Nonnull final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); + } + final Map flags = new HashMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + final String flagKey = reader.nextName(); + final Object rawFlag = reader.readJsonValue(); + try { + final Flag flag = flagAdapter.fromJsonValue(rawFlag); + if (flag != null) { + flags.put(flagKey, flag); + } + } catch (JsonDataException | IllegalArgumentException ignored) { + // A malformed flag must not prevent other flags in the same config from evaluating. + } + } + reader.endObject(); + return flags; + } + + @Override + public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map value) + throws IOException { + throw new UnsupportedOperationException("Reading only adapter"); + } + } + static class DateAdapter extends JsonAdapter { @Nullable @@ -81,10 +146,8 @@ public Date fromJson(@Nonnull final JsonReader reader) throws IOException { return null; } try { - // Use OffsetDateTime which handles variable precision fractional seconds (0-9 digits) - // and UTC offsets (+01:00, -05:00, Z) - final OffsetDateTime odt = OffsetDateTime.parse(date); - return Date.from(odt.toInstant()); + final Instant instant = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); + return Date.from(instant); } catch (Exception e) { // ignore wrongly set dates return null; diff --git a/products/feature-flagging/feature-flagging-lib/src/test/groovy/com/datadog/featureflag/RemoteConfigServiceTest.groovy b/products/feature-flagging/feature-flagging-lib/src/test/groovy/com/datadog/featureflag/RemoteConfigServiceTest.groovy deleted file mode 100644 index 7ae78dd61fc..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/groovy/com/datadog/featureflag/RemoteConfigServiceTest.groovy +++ /dev/null @@ -1,130 +0,0 @@ -package com.datadog.featureflag - -import com.squareup.moshi.JsonReader -import com.squareup.moshi.JsonWriter -import datadog.communication.ddagent.SharedCommunicationObjects -import datadog.remoteconfig.Capabilities -import datadog.remoteconfig.ConfigurationDeserializer -import datadog.remoteconfig.ConfigurationPoller -import datadog.remoteconfig.PollingRateHinter -import datadog.remoteconfig.Product -import datadog.trace.api.Config -import datadog.trace.api.featureflag.FeatureFlaggingGateway -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration -import datadog.trace.test.util.DDSpecification - -class RemoteConfigServiceTest extends DDSpecification { - - private FeatureFlaggingGateway.ConfigListener listener - - void setup() { - listener = Mock(FeatureFlaggingGateway.ConfigListener) - } - - void cleanup() { - FeatureFlaggingGateway.removeConfigListener(listener) - } - - void 'test new config received'() { - setup: - def poller = Mock(ConfigurationPoller) - final sco = Stub(SharedCommunicationObjects) { - configurationPoller(_ as Config) >> poller - } - FeatureFlaggingGateway.addConfigListener(listener) - final service = new RemoteConfigServiceImpl(sco, Config.get()) - final config = """ -{ - "createdAt":"2024-04-17T19:40:53.716Z", - "format":"SERVER", - "environment":{ - "name":"Test" - }, - "flags":{ - - } -} -""".bytes - ConfigurationDeserializer deserializer = null - - when: - service.init() - - then: - 1 * poller.addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES) - 1 * poller.addListener(Product.FFE_FLAGS, _ as ConfigurationDeserializer, _) >> { - deserializer = it[1] as ConfigurationDeserializer - } - - when: - service.accept('test', deserializer.deserialize(config), Mock(PollingRateHinter)) - - then: - 1 * listener.accept(_ as ServerConfiguration) - - when: - service.close() - - then: - 1 * poller.removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES) - 1 * poller.removeListeners(Product.FFE_FLAGS) - - cleanup: - FeatureFlaggingGateway.removeConfigListener(listener) - } - - void 'test date parsing'() { - given: - final reader = Stub(JsonReader) { - nextString() >> string - } - final adapter = new RemoteConfigServiceImpl.DateAdapter() - - when: - final date = adapter.fromJson(reader) - - then: - date == expected - - where: - string | expected - // Valid ISO 8601 formats - no fractional seconds - "2023-01-01T00:00:00Z" | new Date(1672531200000L) // 2023-01-01 00:00:00 UTC - "2023-12-31T23:59:59Z" | new Date(1704067199000L) // 2023-12-31 23:59:59 UTC - "2024-02-29T12:00:00Z" | new Date(1709208000000L) // Leap year date - // 3-digit milliseconds - "2023-01-01T00:00:00.000Z" | new Date(1672531200000L) // With milliseconds - "2023-06-15T14:30:45.123Z" | new Date(1686839445123L) // With milliseconds - // 6-digit microseconds (truncated to milliseconds by Java Date) - "2023-06-15T14:30:45.123456Z" | new Date(1686839445123L) // Microseconds truncated - "2023-06-15T14:30:45.235982Z" | new Date(1686839445235L) // Different microseconds from backend - // 9-digit nanoseconds (truncated to milliseconds by Java Date) - "2023-06-15T14:30:45.123456789Z" | new Date(1686839445123L) // Nanoseconds truncated - // Variable precision fractional seconds - "2023-06-15T14:30:45.1Z" | new Date(1686839445100L) // 1-digit - "2023-06-15T14:30:45.12Z" | new Date(1686839445120L) // 2-digit - // UTC offsets (supported by OffsetDateTime.parse) - "2023-01-01T01:00:00+01:00" | new Date(1672531200000L) // UTC+1 = 2023-01-01 00:00:00 UTC - "2023-01-01T00:00:00-05:00" | new Date(1672549200000L) // UTC-5 = 2023-01-01 05:00:00 UTC - // Non supported formats should return null - "2023-01-01" | null // Date only - "invalid-date" | null - "" | null - "not-a-date" | null - "2023/01/01T00:00:00Z" | null // Wrong separator - - // Null input - null | null - } - - void 'test parsing only adapter'() { - given: - final adapter = new RemoteConfigServiceImpl.DateAdapter() - - when: - adapter.toJson(Stub(JsonWriter), new Date()) - - then: - thrown(UnsupportedOperationException) - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java new file mode 100644 index 00000000000..4b8eb568cb6 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java @@ -0,0 +1,238 @@ +package com.datadog.featureflag; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.remoteconfig.Capabilities; +import datadog.remoteconfig.ConfigurationDeserializer; +import datadog.remoteconfig.ConfigurationPoller; +import datadog.remoteconfig.PollingRateHinter; +import datadog.remoteconfig.Product; +import datadog.trace.api.Config; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import java.time.Instant; +import java.util.Date; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.tabletest.junit.TableTest; + +@ExtendWith(MockitoExtension.class) +class RemoteConfigServiceImplTest { + + @Mock private FeatureFlaggingGateway.ConfigListener listener; + @Captor private ArgumentCaptor deserializerCaptor; + + @AfterEach + void cleanup() { + FeatureFlaggingGateway.removeConfigListener(listener); + } + + @Test + void testNewConfigReceived() throws Exception { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); + when(sco.configurationPoller(any(Config.class))).thenReturn(poller); + FeatureFlaggingGateway.addConfigListener(listener); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(sco, Config.get()); + + service.init(); + + verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller).addListener(eq(Product.FFE_FLAGS), deserializerCaptor.capture(), eq(service)); + + final ServerConfiguration config = deserializer().deserialize(emptyConfig().getBytes(UTF_8)); + service.accept("test", config, mock(PollingRateHinter.class)); + + verify(listener).accept(any(ServerConfiguration.class)); + + service.close(); + + verify(poller).removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller).removeListeners(Product.FFE_FLAGS); + } + + @Test + void skipsMalformedFlagAllocationsAndKeepsValidFlag() throws Exception { + final ServerConfiguration config = + deserialize( + "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"format\":\"SERVER\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{" + + "\"malformed-flag\":{" + + "\"key\":\"malformed-flag\"," + + "\"enabled\":true," + + "\"variationType\":\"STRING\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"on\"}}," + + "\"allocations\":\"this-is-not-a-list\"" + + "}," + + "\"valid-flag\":{" + + "\"key\":\"valid-flag\"," + + "\"enabled\":true," + + "\"variationType\":\"STRING\"," + + "\"variations\":{\"expected\":{\"key\":\"expected\",\"value\":\"expected\"}}," + + "\"allocations\":[{" + + "\"key\":\"default-allocation\"," + + "\"rules\":[]," + + "\"splits\":[{\"variationKey\":\"expected\",\"shards\":[]}]," + + "\"doLog\":true" + + "}]" + + "}" + + "}" + + "}"); + + assertNotNull(config); + assertFalse(config.flags.containsKey("malformed-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); + } + + @Test + void ignoresUnknownTopLevelFields() throws Exception { + final ServerConfiguration config = + deserialize( + "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"format\":\"SERVER\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"segments\":{\"new-schema-key\":{\"ignored\":true}}," + + "\"flags\":{}" + + "}"); + + assertNotNull(config); + assertEquals("2024-04-17T19:40:53.716Z", config.createdAt); + assertEquals("SERVER", config.format); + assertNotNull(config.environment); + assertEquals("Test", config.environment.name); + assertTrue(config.flags.isEmpty()); + } + + @Test + void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { + final ServerConfiguration config = + deserialize( + "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"format\":\"SERVER\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{" + + "\"operator-grease-flag\":{" + + "\"key\":\"operator-grease-flag\"," + + "\"enabled\":true," + + "\"variationType\":\"STRING\"," + + "\"variations\":{\"trap\":{\"key\":\"trap\",\"value\":\"trap\"}}," + + "\"allocations\":[{" + + "\"key\":\"grease-allocation\"," + + "\"rules\":[{\"conditions\":[{" + + "\"attribute\":\"country\"," + + "\"operator\":\"not-a-real-operator\"," + + "\"value\":\"anything\"" + + "}]}]," + + "\"splits\":[{\"variationKey\":\"trap\",\"shards\":[]}]," + + "\"doLog\":true" + + "}]" + + "}," + + "\"valid-flag\":{" + + "\"key\":\"valid-flag\"," + + "\"enabled\":true," + + "\"variationType\":\"STRING\"," + + "\"variations\":{\"expected\":{\"key\":\"expected\",\"value\":\"expected\"}}," + + "\"allocations\":[{" + + "\"key\":\"default-allocation\"," + + "\"rules\":[]," + + "\"splits\":[{\"variationKey\":\"expected\",\"shards\":[]}]," + + "\"doLog\":true" + + "}]" + + "}" + + "}" + + "}"); + + assertNotNull(config); + assertFalse(config.flags.containsKey("operator-grease-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); + } + + @TableTest({ + "scenario | value | expectedEpochMilli", + "utc second | '2023-01-01T00:00:00Z' | 1672531200000 ", + "utc end of year | '2023-12-31T23:59:59Z' | 1704067199000 ", + "leap day | '2024-02-29T12:00:00Z' | 1709208000000 ", + "millisecond precision | '2023-01-01T00:00:00.000Z' | 1672531200000 ", + "three fractional digits | '2023-06-15T14:30:45.123Z' | 1686839445123 ", + "six fractional digits truncate to millis | '2023-06-15T14:30:45.123456Z' | 1686839445123 ", + "six fractional digits preserve millis | '2023-06-15T14:30:45.235982Z' | 1686839445235 ", + "nine fractional digits truncate to millis | '2023-06-15T14:30:45.123456789Z' | 1686839445123 ", + "one fractional digit | '2023-06-15T14:30:45.1Z' | 1686839445100 ", + "two fractional digits | '2023-06-15T14:30:45.12Z' | 1686839445120 ", + "positive offset | '2023-01-01T01:00:00+01:00' | 1672531200000 ", + "negative offset | '2023-01-01T00:00:00-05:00' | 1672549200000 ", + "date only | '2023-01-01' | ", + "invalid | 'invalid-date' | ", + "empty string | '' | ", + "not a date | 'not-a-date' | ", + "slash date | '2023/01/01T00:00:00Z' | ", + "null | | " + }) + void testDateParsing(final String value, final Long expectedEpochMilli) throws Exception { + final JsonReader reader = mock(JsonReader.class); + when(reader.nextString()).thenReturn(value); + final RemoteConfigServiceImpl.DateAdapter adapter = new RemoteConfigServiceImpl.DateAdapter(); + + final Date parsed = adapter.fromJson(reader); + if (expectedEpochMilli == null) { + assertNull(parsed); + } else { + assertNotNull(parsed); + assertEquals(Instant.ofEpochMilli(expectedEpochMilli), parsed.toInstant()); + } + } + + @Test + void testParsingOnlyAdapter() { + final RemoteConfigServiceImpl.DateAdapter adapter = new RemoteConfigServiceImpl.DateAdapter(); + + assertThrows( + UnsupportedOperationException.class, + () -> adapter.toJson(mock(JsonWriter.class), new Date())); + } + + @SuppressWarnings("unchecked") + private ConfigurationDeserializer deserializer() { + return deserializerCaptor.getValue(); + } + + private static ServerConfiguration deserialize(final String json) throws Exception { + return RemoteConfigServiceImpl.UniversalFlagConfigDeserializer.INSTANCE.deserialize( + json.getBytes(UTF_8)); + } + + private static String emptyConfig() { + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"format\":\"SERVER\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}" + + "}"; + } +} From 2eaf305a1cd5cf80e1974154fa9cdf4d74aff084 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 12:52:54 -0400 Subject: [PATCH 2/4] Use Instant for FFE allocation windows --- .../trace/api/openfeature/DDEvaluator.java | 14 ++--- .../api/openfeature/DDEvaluatorTest.java | 23 +++----- .../api/featureflag/ufc/v1/Allocation.java | 10 ++-- .../featureflag/RemoteConfigServiceImpl.java | 15 ++--- .../RemoteConfigServiceImplTest.java | 55 ++++++++++--------- 5 files changed, 55 insertions(+), 62 deletions(-) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java index 91c0aafdc7a..fd787413437 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java @@ -26,8 +26,8 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.Instant; import java.util.AbstractMap; -import java.util.Date; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; @@ -114,7 +114,7 @@ public ProviderEvaluation evaluate( return error(defaultValue, ErrorCode.GENERAL, "Missing allocations for flag " + key); } - final Date now = new Date(); + final Instant now = Instant.now(); final String targetingKey = context.getTargetingKey(); for (final Allocation allocation : flag.allocations) { @@ -197,14 +197,14 @@ private static boolean isEmpty(final List list) { return list == null || list.isEmpty(); } - private static boolean isAllocationActive(final Allocation allocation, final Date now) { - final Date startDate = allocation.startAt; - if (startDate != null && now.before(startDate)) { + private static boolean isAllocationActive(final Allocation allocation, final Instant now) { + final Instant startDate = allocation.startAt; + if (startDate != null && now.isBefore(startDate)) { return false; } - final Date endDate = allocation.endAt; - if (endDate != null && now.after(endDate)) { + final Instant endDate = allocation.endAt; + if (endDate != null && now.isAfter(endDate)) { return false; } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java index bb86c409bad..8a1ff05276c 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java @@ -45,15 +45,12 @@ import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; -import java.text.ParseException; -import java.text.SimpleDateFormat; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; -import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.TimeZone; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -129,7 +126,7 @@ private static Arguments[] valueMappingTestCases() { Arguments.of(Value.class, null, null), // Unsupported - Arguments.of(Date.class, "21-12-2023", IllegalArgumentException.class), + Arguments.of(Long.class, 42L, IllegalArgumentException.class), }; } @@ -763,8 +760,8 @@ private Flag createTimeBasedFlag() { new Allocation( "time-alloc", null, - parseDate("2022-01-01T00:00:00Z"), - parseDate("2022-12-31T23:59:59Z"), + parseInstant("2022-01-01T00:00:00Z"), + parseInstant("2022-12-31T23:59:59Z"), splits, false)); @@ -1170,7 +1167,7 @@ private Flag createFutureAllocationFlag() { // Allocation that starts in the future (2050) final Allocation allocation = new Allocation( - "future-alloc", null, parseDate("2050-01-01T00:00:00Z"), null, splits, false); + "future-alloc", null, parseInstant("2050-01-01T00:00:00Z"), null, splits, false); return new Flag( "future-allocation-flag", true, ValueType.STRING, variants, singletonList(allocation)); @@ -1368,13 +1365,7 @@ private static Map mapOf(final Object... props) { return result; } - private static Date parseDate(String dateString) { - try { - SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); - formatter.setTimeZone(TimeZone.getTimeZone("UTC")); - return formatter.parse(dateString); - } catch (ParseException e) { - throw new RuntimeException("Failed to parse date: " + dateString, e); - } + private static Instant parseInstant(String value) { + return Instant.parse(value); } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java index 2ce031243bb..9451f1222f7 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java @@ -1,21 +1,21 @@ package datadog.trace.api.featureflag.ufc.v1; -import java.util.Date; +import java.time.Instant; import java.util.List; public class Allocation { public final String key; public final List rules; - public final Date startAt; - public final Date endAt; + public final Instant startAt; + public final Instant endAt; public final List splits; public final Boolean doLog; public Allocation( final String key, final List rules, - final Date startAt, - final Date endAt, + final Instant startAt, + final Instant endAt, final List splits, final Boolean doLog) { this.key = key; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index ec28f684a96..1e791a916ca 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java @@ -23,7 +23,6 @@ import java.lang.reflect.Type; import java.time.Instant; import java.time.format.DateTimeFormatter; -import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -69,7 +68,10 @@ static class UniversalFlagConfigDeserializer static final UniversalFlagConfigDeserializer INSTANCE = new UniversalFlagConfigDeserializer(); private static final Moshi MOSHI = - new Moshi.Builder().add(Date.class, new DateAdapter()).add(FlagMapAdapter.FACTORY).build(); + new Moshi.Builder() + .add(Instant.class, new InstantAdapter()) + .add(FlagMapAdapter.FACTORY) + .build(); private static final JsonAdapter V1_ADAPTER = MOSHI.adapter(ServerConfiguration.class); @@ -136,18 +138,17 @@ public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map { + static class InstantAdapter extends JsonAdapter { @Nullable @Override - public Date fromJson(@Nonnull final JsonReader reader) throws IOException { + public Instant fromJson(@Nonnull final JsonReader reader) throws IOException { final String date = reader.nextString(); if (date == null) { return null; } try { - final Instant instant = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); - return Date.from(instant); + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); } catch (Exception e) { // ignore wrongly set dates return null; @@ -155,7 +156,7 @@ public Date fromJson(@Nonnull final JsonReader reader) throws IOException { } @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Date value) + public void toJson(@Nonnull final JsonWriter writer, @Nullable final Instant value) throws IOException { throw new UnsupportedOperationException("Reading only adapter"); } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java index 4b8eb568cb6..f954eac068d 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java @@ -25,7 +25,6 @@ import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import java.time.Instant; -import java.util.Date; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -174,47 +173,49 @@ void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { } @TableTest({ - "scenario | value | expectedEpochMilli", - "utc second | '2023-01-01T00:00:00Z' | 1672531200000 ", - "utc end of year | '2023-12-31T23:59:59Z' | 1704067199000 ", - "leap day | '2024-02-29T12:00:00Z' | 1709208000000 ", - "millisecond precision | '2023-01-01T00:00:00.000Z' | 1672531200000 ", - "three fractional digits | '2023-06-15T14:30:45.123Z' | 1686839445123 ", - "six fractional digits truncate to millis | '2023-06-15T14:30:45.123456Z' | 1686839445123 ", - "six fractional digits preserve millis | '2023-06-15T14:30:45.235982Z' | 1686839445235 ", - "nine fractional digits truncate to millis | '2023-06-15T14:30:45.123456789Z' | 1686839445123 ", - "one fractional digit | '2023-06-15T14:30:45.1Z' | 1686839445100 ", - "two fractional digits | '2023-06-15T14:30:45.12Z' | 1686839445120 ", - "positive offset | '2023-01-01T01:00:00+01:00' | 1672531200000 ", - "negative offset | '2023-01-01T00:00:00-05:00' | 1672549200000 ", - "date only | '2023-01-01' | ", - "invalid | 'invalid-date' | ", - "empty string | '' | ", - "not a date | 'not-a-date' | ", - "slash date | '2023/01/01T00:00:00Z' | ", - "null | | " + "scenario | value | expectedInstant ", + "utc second | '2023-01-01T00:00:00Z' | '2023-01-01T00:00:00Z' ", + "utc end of year | '2023-12-31T23:59:59Z' | '2023-12-31T23:59:59Z' ", + "leap day | '2024-02-29T12:00:00Z' | '2024-02-29T12:00:00Z' ", + "millisecond precision | '2023-01-01T00:00:00.000Z' | '2023-01-01T00:00:00Z' ", + "three fractional digits | '2023-06-15T14:30:45.123Z' | '2023-06-15T14:30:45.123Z' ", + "six fractional digits | '2023-06-15T14:30:45.123456Z' | '2023-06-15T14:30:45.123456Z' ", + "six fractional digits preserve ms | '2023-06-15T14:30:45.235982Z' | '2023-06-15T14:30:45.235982Z' ", + "nine fractional digits | '2023-06-15T14:30:45.123456789Z' | '2023-06-15T14:30:45.123456789Z'", + "one fractional digit | '2023-06-15T14:30:45.1Z' | '2023-06-15T14:30:45.100Z' ", + "two fractional digits | '2023-06-15T14:30:45.12Z' | '2023-06-15T14:30:45.120Z' ", + "positive offset | '2023-01-01T01:00:00+01:00' | '2023-01-01T00:00:00Z' ", + "negative offset | '2023-01-01T00:00:00-05:00' | '2023-01-01T05:00:00Z' ", + "date only | '2023-01-01' | ", + "invalid | 'invalid-date' | ", + "empty string | '' | ", + "not a date | 'not-a-date' | ", + "slash date | '2023/01/01T00:00:00Z' | ", + "null | | " }) - void testDateParsing(final String value, final Long expectedEpochMilli) throws Exception { + void testInstantParsing(final String value, final String expectedInstant) throws Exception { final JsonReader reader = mock(JsonReader.class); when(reader.nextString()).thenReturn(value); - final RemoteConfigServiceImpl.DateAdapter adapter = new RemoteConfigServiceImpl.DateAdapter(); + final RemoteConfigServiceImpl.InstantAdapter adapter = + new RemoteConfigServiceImpl.InstantAdapter(); - final Date parsed = adapter.fromJson(reader); - if (expectedEpochMilli == null) { + final Instant parsed = adapter.fromJson(reader); + if (expectedInstant == null) { assertNull(parsed); } else { assertNotNull(parsed); - assertEquals(Instant.ofEpochMilli(expectedEpochMilli), parsed.toInstant()); + assertEquals(Instant.parse(expectedInstant), parsed); } } @Test void testParsingOnlyAdapter() { - final RemoteConfigServiceImpl.DateAdapter adapter = new RemoteConfigServiceImpl.DateAdapter(); + final RemoteConfigServiceImpl.InstantAdapter adapter = + new RemoteConfigServiceImpl.InstantAdapter(); assertThrows( UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), new Date())); + () -> adapter.toJson(mock(JsonWriter.class), Instant.EPOCH)); } @SuppressWarnings("unchecked") From d69f39bd31e29a136ee4b57e84b2a9b77b084b80 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 13:15:39 -0400 Subject: [PATCH 3/4] Log dropped malformed FFE flags --- .../datadog/featureflag/RemoteConfigServiceImpl.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index 1e791a916ca..4b9c2d3a6e1 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java @@ -29,10 +29,14 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import okio.Okio; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class RemoteConfigServiceImpl implements RemoteConfigService, ConfigurationChangesTypedListener { + private static final Logger LOGGER = LoggerFactory.getLogger(RemoteConfigServiceImpl.class); + private final ConfigurationPoller configurationPoller; public RemoteConfigServiceImpl(final SharedCommunicationObjects sco, final Config config) { @@ -123,8 +127,11 @@ public Map fromJson(@Nonnull final JsonReader reader) throws IOExc if (flag != null) { flags.put(flagKey, flag); } - } catch (JsonDataException | IllegalArgumentException ignored) { - // A malformed flag must not prevent other flags in the same config from evaluating. + } catch (JsonDataException | IllegalArgumentException error) { + LOGGER.warn( + "Dropping malformed FFE flag {} during remote config deserialization: {}", + flagKey, + error.toString()); } } reader.endObject(); From 67712dd6a30f29086bff28c072e9b077c1f4b7c6 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 3 Jun 2026 16:04:51 -0400 Subject: [PATCH 4/4] Clarify FFE instant parser test label --- .../RemoteConfigServiceImplTest.java | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java index f954eac068d..d504223c1a2 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java @@ -173,25 +173,25 @@ void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { } @TableTest({ - "scenario | value | expectedInstant ", - "utc second | '2023-01-01T00:00:00Z' | '2023-01-01T00:00:00Z' ", - "utc end of year | '2023-12-31T23:59:59Z' | '2023-12-31T23:59:59Z' ", - "leap day | '2024-02-29T12:00:00Z' | '2024-02-29T12:00:00Z' ", - "millisecond precision | '2023-01-01T00:00:00.000Z' | '2023-01-01T00:00:00Z' ", - "three fractional digits | '2023-06-15T14:30:45.123Z' | '2023-06-15T14:30:45.123Z' ", - "six fractional digits | '2023-06-15T14:30:45.123456Z' | '2023-06-15T14:30:45.123456Z' ", - "six fractional digits preserve ms | '2023-06-15T14:30:45.235982Z' | '2023-06-15T14:30:45.235982Z' ", - "nine fractional digits | '2023-06-15T14:30:45.123456789Z' | '2023-06-15T14:30:45.123456789Z'", - "one fractional digit | '2023-06-15T14:30:45.1Z' | '2023-06-15T14:30:45.100Z' ", - "two fractional digits | '2023-06-15T14:30:45.12Z' | '2023-06-15T14:30:45.120Z' ", - "positive offset | '2023-01-01T01:00:00+01:00' | '2023-01-01T00:00:00Z' ", - "negative offset | '2023-01-01T00:00:00-05:00' | '2023-01-01T05:00:00Z' ", - "date only | '2023-01-01' | ", - "invalid | 'invalid-date' | ", - "empty string | '' | ", - "not a date | 'not-a-date' | ", - "slash date | '2023/01/01T00:00:00Z' | ", - "null | | " + "scenario | value | expectedInstant ", + "utc second | '2023-01-01T00:00:00Z' | '2023-01-01T00:00:00Z' ", + "utc end of year | '2023-12-31T23:59:59Z' | '2023-12-31T23:59:59Z' ", + "leap day | '2024-02-29T12:00:00Z' | '2024-02-29T12:00:00Z' ", + "millisecond precision | '2023-01-01T00:00:00.000Z' | '2023-01-01T00:00:00Z' ", + "three fractional digits | '2023-06-15T14:30:45.123Z' | '2023-06-15T14:30:45.123Z' ", + "six fractional digits | '2023-06-15T14:30:45.123456Z' | '2023-06-15T14:30:45.123456Z' ", + "six fractional digits distinct | '2023-06-15T14:30:45.235982Z' | '2023-06-15T14:30:45.235982Z' ", + "nine fractional digits | '2023-06-15T14:30:45.123456789Z' | '2023-06-15T14:30:45.123456789Z'", + "one fractional digit | '2023-06-15T14:30:45.1Z' | '2023-06-15T14:30:45.100Z' ", + "two fractional digits | '2023-06-15T14:30:45.12Z' | '2023-06-15T14:30:45.120Z' ", + "positive offset | '2023-01-01T01:00:00+01:00' | '2023-01-01T00:00:00Z' ", + "negative offset | '2023-01-01T00:00:00-05:00' | '2023-01-01T05:00:00Z' ", + "date only | '2023-01-01' | ", + "invalid | 'invalid-date' | ", + "empty string | '' | ", + "not a date | 'not-a-date' | ", + "slash date | '2023/01/01T00:00:00Z' | ", + "null | | " }) void testInstantParsing(final String value, final String expectedInstant) throws Exception { final JsonReader reader = mock(JsonReader.class);