From 7772a57349d97b258b6cf9428d8a6289e3fcb3b5 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 2 Jun 2026 12:03:51 -0400 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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); From b132ec03080462e0b523e1689204d356e4b754a1 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 7 Jul 2026 14:45:26 -0400 Subject: [PATCH 05/11] Address FFE Instant review feedback --- .../datadog/trace/api/openfeature/DDEvaluatorTest.java | 10 +++------- .../featureflag/RemoteConfigServiceImplTest.java | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) 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 8a1ff05276c..4648183831e 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 @@ -760,8 +760,8 @@ private Flag createTimeBasedFlag() { new Allocation( "time-alloc", null, - parseInstant("2022-01-01T00:00:00Z"), - parseInstant("2022-12-31T23:59:59Z"), + Instant.parse("2022-01-01T00:00:00Z"), + Instant.parse("2022-12-31T23:59:59Z"), splits, false)); @@ -1167,7 +1167,7 @@ private Flag createFutureAllocationFlag() { // Allocation that starts in the future (2050) final Allocation allocation = new Allocation( - "future-alloc", null, parseInstant("2050-01-01T00:00:00Z"), null, splits, false); + "future-alloc", null, Instant.parse("2050-01-01T00:00:00Z"), null, splits, false); return new Flag( "future-allocation-flag", true, ValueType.STRING, variants, singletonList(allocation)); @@ -1364,8 +1364,4 @@ private static Map mapOf(final Object... props) { } return result; } - - private static Instant parseInstant(String value) { - return Instant.parse(value); - } } 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 0d715619519..93b8263eb57 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 @@ -287,7 +287,7 @@ void testInstantParsing(final String value, final String expectedInstant) throws assertNull(parsed); } else { assertNotNull(parsed); - assertEquals(Instant.parse(expectedInstant), parsed); + assertEquals(expectedInstant, parsed.toString()); } } From 4d1c227e856b20ab1eca997d04c8308d92318356 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 7 Jul 2026 19:27:19 -0400 Subject: [PATCH 06/11] Preserve FFE allocation date compatibility --- .../trace/api/openfeature/DDEvaluator.java | 4 +- .../api/openfeature/DDEvaluatorTest.java | 30 ++++-- .../api/featureflag/ufc/v1/Allocation.java | 17 +++- .../featureflag/RemoteConfigServiceImpl.java | 17 ++-- .../RemoteConfigServiceImplTest.java | 93 +++++++++++++------ 5 files changed, 110 insertions(+), 51 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 fd787413437..e3d513cfd3a 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 @@ -198,12 +198,12 @@ private static boolean isEmpty(final List list) { } private static boolean isAllocationActive(final Allocation allocation, final Instant now) { - final Instant startDate = allocation.startAt; + final Instant startDate = allocation.startAtInstant(); if (startDate != null && now.isBefore(startDate)) { return false; } - final Instant endDate = allocation.endAt; + final Instant endDate = allocation.endAtInstant(); 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 ffd287a9871..3e67f572141 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 @@ -23,6 +23,7 @@ import com.squareup.moshi.JsonWriter; import com.squareup.moshi.Moshi; import com.squareup.moshi.Types; +import datadog.trace.api.featureflag.ufc.v1.Allocation; import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import dev.openfeature.sdk.ErrorCode; @@ -37,7 +38,9 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.Instant; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -55,8 +58,7 @@ public class DDEvaluatorTest { private static final String CANONICAL_FIXTURE_PATH = "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; - private static final Moshi MOSHI = - new Moshi.Builder().add(Instant.class, new InstantAdapter()).build(); + private static final Moshi MOSHI = new Moshi.Builder().add(Date.class, new DateAdapter()).build(); private static final JsonAdapter CONFIG_ADAPTER = MOSHI.adapter(ServerConfiguration.class); private static final Type FIXTURE_LIST_TYPE = @@ -195,6 +197,19 @@ public void testNoAllocations() { assertThat(details.getErrorCode(), nullValue()); } + @Test + public void testAllocationDateAbiAndInstantAccessors() throws Exception { + final Date startAt = Date.from(Instant.parse("2024-01-01T00:00:00Z")); + final Date endAt = Date.from(Instant.parse("2024-12-31T23:59:59Z")); + final Allocation allocation = + new Allocation("allocation", emptyList(), startAt, endAt, emptyList(), true); + + assertThat(Allocation.class.getField("startAt").getType(), equalTo(Date.class)); + assertThat(Allocation.class.getField("endAt").getType(), equalTo(Date.class)); + assertThat(allocation.startAtInstant(), equalTo(startAt.toInstant())); + assertThat(allocation.endAtInstant(), equalTo(endAt.toInstant())); + } + private static Arguments[] flatteningTestCases() { final List arguments = new ArrayList<>(); arguments.add(Arguments.of(emptyMap(), emptyMap())); @@ -386,26 +401,27 @@ private static final class FixtureResult { Map flagMetadata = emptyMap(); } - private static final class InstantAdapter extends JsonAdapter { + private static final class DateAdapter extends JsonAdapter { @Override - public Instant fromJson(final JsonReader reader) throws IOException { + public Date fromJson(final JsonReader reader) throws IOException { if (reader.peek() == JsonReader.Token.NULL) { return reader.nextNull(); } try { - return Instant.parse(reader.nextString()); + return Date.from( + DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(reader.nextString(), Instant::from)); } catch (final Exception ignored) { return null; } } @Override - public void toJson(final JsonWriter writer, final Instant value) throws IOException { + public void toJson(final JsonWriter writer, final Date value) throws IOException { if (value == null) { writer.nullValue(); return; } - writer.value(value.toString()); + writer.value(value.toInstant().toString()); } } } 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 9451f1222f7..2a8ca6001e0 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,22 @@ package datadog.trace.api.featureflag.ufc.v1; import java.time.Instant; +import java.util.Date; import java.util.List; public class Allocation { public final String key; public final List rules; - public final Instant startAt; - public final Instant endAt; + public final Date startAt; + public final Date endAt; public final List splits; public final Boolean doLog; public Allocation( final String key, final List rules, - final Instant startAt, - final Instant endAt, + final Date startAt, + final Date endAt, final List splits, final Boolean doLog) { this.key = key; @@ -25,4 +26,12 @@ public Allocation( this.splits = splits; this.doLog = doLog; } + + public Instant startAtInstant() { + return startAt == null ? null : startAt.toInstant(); + } + + public Instant endAtInstant() { + return endAt == null ? null : endAt.toInstant(); + } } 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 4b9c2d3a6e1..d7230f29be5 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,6 +23,7 @@ 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; @@ -72,10 +73,7 @@ static class UniversalFlagConfigDeserializer static final UniversalFlagConfigDeserializer INSTANCE = new UniversalFlagConfigDeserializer(); private static final Moshi MOSHI = - new Moshi.Builder() - .add(Instant.class, new InstantAdapter()) - .add(FlagMapAdapter.FACTORY) - .build(); + new Moshi.Builder().add(Date.class, new DateAdapter()).add(FlagMapAdapter.FACTORY).build(); private static final JsonAdapter V1_ADAPTER = MOSHI.adapter(ServerConfiguration.class); @@ -145,17 +143,20 @@ public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map { + static class DateAdapter extends JsonAdapter { @Nullable @Override - public Instant fromJson(@Nonnull final JsonReader reader) throws IOException { + public Date fromJson(@Nonnull final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); + } final String date = reader.nextString(); if (date == null) { return null; } try { - return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); + return Date.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from)); } catch (Exception e) { // ignore wrongly set dates return null; @@ -163,7 +164,7 @@ public Instant fromJson(@Nonnull final JsonReader reader) throws IOException { } @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Instant value) + public void toJson(@Nonnull final JsonWriter writer, @Nullable final Date 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 93b8263eb57..7e7f0b49a74 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 @@ -29,11 +29,13 @@ import datadog.remoteconfig.Product; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.ufc.v1.Allocation; import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import java.lang.annotation.Annotation; import java.lang.reflect.Type; import java.time.Instant; +import java.util.Date; import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -136,6 +138,41 @@ void ignoresUnknownTopLevelFields() throws Exception { assertTrue(config.flags.isEmpty()); } + @Test + void parsesAllocationWindowDatesAsDateFieldsWithInstantAccessors() throws Exception { + final ServerConfiguration config = + deserialize( + "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"format\":\"SERVER\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{" + + "\"dated-flag\":{" + + "\"key\":\"dated-flag\"," + + "\"enabled\":true," + + "\"variationType\":\"STRING\"," + + "\"variations\":{\"expected\":{\"key\":\"expected\",\"value\":\"expected\"}}," + + "\"allocations\":[{" + + "\"key\":\"dated-allocation\"," + + "\"rules\":[]," + + "\"startAt\":\"2023-01-01T01:00:00+01:00\"," + + "\"endAt\":\"2023-01-02T00:00:00Z\"," + + "\"splits\":[{\"variationKey\":\"expected\",\"shards\":[]}]," + + "\"doLog\":true" + + "}]" + + "}" + + "}" + + "}"); + + final Allocation allocation = config.flags.get("dated-flag").allocations.get(0); + assertEquals(Date.class, Allocation.class.getField("startAt").getType()); + assertEquals(Date.class, Allocation.class.getField("endAt").getType()); + assertEquals(Instant.parse("2023-01-01T00:00:00Z"), allocation.startAt.toInstant()); + assertEquals(Instant.parse("2023-01-02T00:00:00Z"), allocation.endAt.toInstant()); + assertEquals(Instant.parse("2023-01-01T00:00:00Z"), allocation.startAtInstant()); + assertEquals(Instant.parse("2023-01-02T00:00:00Z"), allocation.endAtInstant()); + } + @Test void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { final ServerConfiguration config = @@ -256,49 +293,47 @@ void flagMapAdapterIsReadOnly() { } @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 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 | | " + "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.123Z'", + "six fractional digits distinct | '2023-06-15T14:30:45.235982Z' | '2023-06-15T14:30:45.235Z'", + "nine fractional digits | '2023-06-15T14:30:45.123456789Z' | '2023-06-15T14:30:45.123Z'", + "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 { + void testDateParsing(final String value, final String expectedInstant) throws Exception { final JsonReader reader = mock(JsonReader.class); when(reader.nextString()).thenReturn(value); - final RemoteConfigServiceImpl.InstantAdapter adapter = - new RemoteConfigServiceImpl.InstantAdapter(); + final RemoteConfigServiceImpl.DateAdapter adapter = new RemoteConfigServiceImpl.DateAdapter(); - final Instant parsed = adapter.fromJson(reader); + final Date parsed = adapter.fromJson(reader); if (expectedInstant == null) { assertNull(parsed); } else { assertNotNull(parsed); - assertEquals(expectedInstant, parsed.toString()); + assertEquals(expectedInstant, parsed.toInstant().toString()); } } @Test void testParsingOnlyAdapter() { - final RemoteConfigServiceImpl.InstantAdapter adapter = - new RemoteConfigServiceImpl.InstantAdapter(); + final RemoteConfigServiceImpl.DateAdapter adapter = new RemoteConfigServiceImpl.DateAdapter(); assertThrows( UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), Instant.EPOCH)); + () -> adapter.toJson(mock(JsonWriter.class), Date.from(Instant.EPOCH))); } @SuppressWarnings("unchecked") @@ -312,9 +347,7 @@ private static ServerConfiguration deserialize(final String json) throws Excepti } private static Moshi moshi() { - return new Moshi.Builder() - .add(Instant.class, new RemoteConfigServiceImpl.InstantAdapter()) - .build(); + return new Moshi.Builder().add(Date.class, new RemoteConfigServiceImpl.DateAdapter()).build(); } private static String emptyConfig() { From 4cb00dc4a3b244ac13488baca34f1de7ca28e02a Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sun, 2 Aug 2026 21:31:38 +0000 Subject: [PATCH 07/11] Preserve microsecond precision in FFE allocation windows --- .../trace/api/openfeature/DDEvaluator.java | 2 +- .../api/openfeature/DDEvaluatorTest.java | 14 +++ .../api/featureflag/ufc/v1/Allocation.java | 47 +++++++++- .../UniversalFlagConfigParser.java | 92 ++++++++++++++++--- .../RemoteConfigServiceImplTest.java | 71 +++++++------- 5 files changed, 176 insertions(+), 50 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 53e53170019..7082b146cf5 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 @@ -208,7 +208,7 @@ private static boolean isEmpty(final List list) { return list == null || list.isEmpty(); } - private static boolean isAllocationActive(final Allocation allocation, final Instant now) { + static boolean isAllocationActive(final Allocation allocation, final Instant now) { final Instant startDate = allocation.startAtInstant(); if (startDate != null && now.isBefore(startDate)) { 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 d24dd19cd34..c586ceab8e0 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 @@ -227,6 +227,20 @@ public void testAllocationDateAbiAndInstantAccessors() throws Exception { assertThat(allocation.endAtInstant(), equalTo(endAt.toInstant())); } + @Test + public void testAllocationWindowHonorsMicrosecondPrecision() { + final Instant startAt = Instant.parse("2024-01-01T00:00:00.123456Z"); + final Instant endAt = Instant.parse("2024-01-01T00:00:00.987654Z"); + final Allocation allocation = + Allocation.fromInstants("allocation", emptyList(), startAt, endAt, emptyList(), true); + + assertThat( + DDEvaluator.isAllocationActive(allocation, startAt.minusNanos(1_000)), equalTo(false)); + assertThat(DDEvaluator.isAllocationActive(allocation, startAt), equalTo(true)); + assertThat(DDEvaluator.isAllocationActive(allocation, endAt), equalTo(true)); + assertThat(DDEvaluator.isAllocationActive(allocation, endAt.plusNanos(1_000)), equalTo(false)); + } + private static Arguments[] flatteningTestCases() { final List arguments = new ArrayList<>(); arguments.add(Arguments.of(emptyMap(), emptyMap())); 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 2a8ca6001e0..b572e7e5aff 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 @@ -12,6 +12,9 @@ public class Allocation { public final List splits; public final Boolean doLog; + private final transient Instant preciseStartAt; + private final transient Instant preciseEndAt; + public Allocation( final String key, final List rules, @@ -19,19 +22,59 @@ public Allocation( final Date endAt, final List splits, final Boolean doLog) { + this( + key, + rules, + startAt, + endAt, + splits, + doLog, + startAt == null ? null : startAt.toInstant(), + endAt == null ? null : endAt.toInstant()); + } + + private Allocation( + final String key, + final List rules, + final Date startAt, + final Date endAt, + final List splits, + final Boolean doLog, + final Instant preciseStartAt, + final Instant preciseEndAt) { this.key = key; this.rules = rules; this.startAt = startAt; this.endAt = endAt; this.splits = splits; this.doLog = doLog; + this.preciseStartAt = preciseStartAt; + this.preciseEndAt = preciseEndAt; + } + + public static Allocation fromInstants( + final String key, + final List rules, + final Instant startAt, + final Instant endAt, + final List splits, + final Boolean doLog) { + return new Allocation( + key, + rules, + startAt == null ? null : Date.from(startAt), + endAt == null ? null : Date.from(endAt), + splits, + doLog, + startAt, + endAt); } public Instant startAtInstant() { - return startAt == null ? null : startAt.toInstant(); + return preciseStartAt != null ? preciseStartAt : startAt == null ? null : startAt.toInstant(); } public Instant endAtInstant() { - return endAt == null ? null : endAt.toInstant(); + return preciseEndAt != null ? preciseEndAt : endAt == null ? null : endAt.toInstant(); } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java index 60acc4b02c6..f98aa11e008 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java @@ -7,16 +7,19 @@ import com.squareup.moshi.Moshi; import com.squareup.moshi.Types; import datadog.remoteconfig.ConfigurationDeserializer; +import datadog.trace.api.featureflag.ufc.v1.Allocation; import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.Rule; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Split; import java.io.ByteArrayInputStream; import java.io.IOException; 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.List; import java.util.Map; import java.util.Set; import javax.annotation.Nonnull; @@ -33,7 +36,11 @@ final class UniversalFlagConfigParser implements ConfigurationDeserializer V1_ADAPTER = MOSHI.adapter(ServerConfiguration.class); @@ -119,30 +126,89 @@ public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map { + static final class InstantAdapter extends JsonAdapter { @Nullable @Override - public Date fromJson(@Nonnull final JsonReader reader) throws IOException { + public Instant fromJson(@Nonnull final JsonReader reader) throws IOException { if (reader.peek() == JsonReader.Token.NULL) { return reader.nextNull(); } - final String date = reader.nextString(); - if (date == null) { - return null; - } - try { - return Date.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from)); - } catch (Exception e) { - // ignore wrongly set dates + return parseInstant(reader.nextString()); + } + + @Override + public void toJson(@Nonnull final JsonWriter writer, @Nullable final Instant value) + throws IOException { + throw new UnsupportedOperationException("Reading only adapter"); + } + } + + static final class AllocationAdapter extends JsonAdapter { + + 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, Allocation.class)) { + return null; + } + return new AllocationAdapter(moshi.adapter(AllocationJson.class)); + } + }; + + private final JsonAdapter delegate; + + AllocationAdapter(final JsonAdapter delegate) { + this.delegate = delegate; + } + + @Nullable + @Override + public Allocation fromJson(@Nonnull final JsonReader reader) throws IOException { + final AllocationJson allocation = delegate.fromJson(reader); + if (allocation == null) { return null; } + return Allocation.fromInstants( + allocation.key, + allocation.rules, + allocation.startAt, + allocation.endAt, + allocation.splits, + allocation.doLog); } @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Date value) + public void toJson(@Nonnull final JsonWriter writer, @Nullable final Allocation value) throws IOException { throw new UnsupportedOperationException("Reading only adapter"); } } + + static final class AllocationJson { + String key; + List rules; + Instant startAt; + Instant endAt; + List splits; + Boolean doLog; + } + + @Nullable + private static Instant parseInstant(@Nullable final String date) { + if (date == null) { + return null; + } + try { + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); + } catch (Exception e) { + // ignore wrongly set dates + return null; + } + } } 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 2018a94a708..8e43497da2a 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 @@ -156,8 +156,8 @@ void parsesAllocationWindowDatesAsDateFieldsWithInstantAccessors() throws Except + "\"allocations\":[{" + "\"key\":\"dated-allocation\"," + "\"rules\":[]," - + "\"startAt\":\"2023-01-01T01:00:00+01:00\"," - + "\"endAt\":\"2023-01-02T00:00:00Z\"," + + "\"startAt\":\"2023-01-01T01:00:00.123456+01:00\"," + + "\"endAt\":\"2023-01-02T00:00:00.987654Z\"," + "\"splits\":[{\"variationKey\":\"expected\",\"shards\":[]}]," + "\"doLog\":true" + "}]" @@ -168,10 +168,10 @@ void parsesAllocationWindowDatesAsDateFieldsWithInstantAccessors() throws Except final Allocation allocation = config.flags.get("dated-flag").allocations.get(0); assertEquals(Date.class, Allocation.class.getField("startAt").getType()); assertEquals(Date.class, Allocation.class.getField("endAt").getType()); - assertEquals(Instant.parse("2023-01-01T00:00:00Z"), allocation.startAt.toInstant()); - assertEquals(Instant.parse("2023-01-02T00:00:00Z"), allocation.endAt.toInstant()); - assertEquals(Instant.parse("2023-01-01T00:00:00Z"), allocation.startAtInstant()); - assertEquals(Instant.parse("2023-01-02T00:00:00Z"), allocation.endAtInstant()); + assertEquals(Instant.parse("2023-01-01T00:00:00.123Z"), allocation.startAt.toInstant()); + assertEquals(Instant.parse("2023-01-02T00:00:00.987Z"), allocation.endAt.toInstant()); + assertEquals(Instant.parse("2023-01-01T00:00:00.123456Z"), allocation.startAtInstant()); + assertEquals(Instant.parse("2023-01-02T00:00:00.987654Z"), allocation.endAtInstant()); } @Test @@ -299,49 +299,49 @@ void flagMapAdapterIsReadOnly() { } @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.123Z'", - "six fractional digits distinct | '2023-06-15T14:30:45.235982Z' | '2023-06-15T14:30:45.235Z'", - "nine fractional digits | '2023-06-15T14:30:45.123456789Z' | '2023-06-15T14:30:45.123Z'", - "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 testDateParsing(final String value, final String expectedInstant) throws Exception { + void testInstantParsing(final String value, final String expectedInstant) throws Exception { final JsonReader reader = mock(JsonReader.class); when(reader.nextString()).thenReturn(value); - final UniversalFlagConfigParser.DateAdapter adapter = - new UniversalFlagConfigParser.DateAdapter(); + final UniversalFlagConfigParser.InstantAdapter adapter = + new UniversalFlagConfigParser.InstantAdapter(); - final Date parsed = adapter.fromJson(reader); + final Instant parsed = adapter.fromJson(reader); if (expectedInstant == null) { assertNull(parsed); } else { assertNotNull(parsed); - assertEquals(expectedInstant, parsed.toInstant().toString()); + assertEquals(expectedInstant, parsed.toString()); } } @Test void testParsingOnlyAdapter() { - final UniversalFlagConfigParser.DateAdapter adapter = - new UniversalFlagConfigParser.DateAdapter(); + final UniversalFlagConfigParser.InstantAdapter adapter = + new UniversalFlagConfigParser.InstantAdapter(); assertThrows( UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), Date.from(Instant.EPOCH))); + () -> adapter.toJson(mock(JsonWriter.class), Instant.EPOCH)); } @SuppressWarnings("unchecked") @@ -354,7 +354,10 @@ private static ServerConfiguration deserialize(final String json) throws Excepti } private static Moshi moshi() { - return new Moshi.Builder().add(Date.class, new UniversalFlagConfigParser.DateAdapter()).build(); + return new Moshi.Builder() + .add(Instant.class, new UniversalFlagConfigParser.InstantAdapter()) + .add(UniversalFlagConfigParser.AllocationAdapter.FACTORY) + .build(); } private static String emptyConfig() { From ad10c7c74135a577ba1e055489aca77ad55136f1 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 5 Aug 2026 20:44:58 +0000 Subject: [PATCH 08/11] Fix exposure context serialization --- .../trace/api/openfeature/DDEvaluator.java | 4 ++- .../api/openfeature/DDEvaluatorTest.java | 2 ++ .../featureflag/ExposureWriterImpl.java | 11 ++++++-- .../featureflag/ExposureWriterTests.java | 28 ++++++++++++++++++- 4 files changed, 41 insertions(+), 4 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 7082b146cf5..fe47bbab16f 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 @@ -546,7 +546,9 @@ static AbstractMap flattenContext(final EvaluationContext contex new FlattenEntry(entry.key + "." + property, structure.getValue(property))); } } else { - result.put(entry.key, context.convertValue(value)); + result.put( + entry.key, + value.isInstant() ? value.asInstant().toString() : context.convertValue(value)); } } } 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 c586ceab8e0..155c5eb1ea8 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 @@ -256,6 +256,8 @@ private static Arguments[] flatteningTestCases() { Arguments.of( mapOf("map", mapOf("key1", 1, "key2", 2, "key3", mapOf("key4", 4))), mapOf("map.key1", 1, "map.key2", 2, "map.key3.key4", 4))); + final Instant instant = Instant.parse("2026-07-10T12:34:56Z"); + arguments.add(Arguments.of(mapOf("instant", instant), mapOf("instant", instant.toString()))); return arguments.toArray(new Arguments[0]); } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java index eddcd520f27..ab4af6938de 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java @@ -178,11 +178,18 @@ protected void flushIfNecessary() { return; } if (shouldFlush()) { + final String requestBodyJson; try { final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); - final String reqBod = jsonAdapter.toJson(exposures); + requestBodyJson = jsonAdapter.toJson(exposures); + } catch (RuntimeException e) { + LOGGER.error("Could not serialize exposures; dropping batch", e); + this.buffer.clear(); + return; + } + try { final RequestBody requestBody = - RequestBody.create(okhttp3.MediaType.parse("application/json"), reqBod); + RequestBody.create(okhttp3.MediaType.parse("application/json"), requestBodyJson); evp.post("exposures", requestBody, stream -> null, null, false); this.buffer.clear(); } catch (Exception e) { diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java index 75c4b3b653b..76b9e2602d8 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java @@ -1,5 +1,6 @@ package com.datadog.featureflag; +import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -222,6 +223,23 @@ void testFailuresAreRetried(boolean finallyFail) throws Exception { } } + @Test + void testSerializationFailureDoesNotPoisonFollowingExposures() throws Exception { + Config config = mockConfig("test-service"); + ExposureEvent validExposure = buildExposure(); + + try (ExposureWriterImpl writer = + new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { + writer.init(); + writer.accept(buildExposure(singletonMap("invalid", (Object) Double.NaN))); + + MILLISECONDS.sleep(300); // wait for the invalid batch to be dropped + writer.accept(validExposure); + + poll.eventually(() -> assertExposures(allExposures(), singletonList(validExposure))); + } + } + @Test void testWriterStopsReceivingExposuresIfEvpProxyIsNotAvailable() throws Exception { SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(false); @@ -398,11 +416,19 @@ private static List buildExposures(int count) { private static ExposureEvent buildExposure() { String id = UUID.randomUUID().toString(); + return buildExposure(id, singletonMap("key_" + id, (Object) ("value_" + id))); + } + + private static ExposureEvent buildExposure(Map attributes) { + return buildExposure(UUID.randomUUID().toString(), attributes); + } + + private static ExposureEvent buildExposure(String id, Map attributes) { return new ExposureEvent( System.currentTimeMillis(), new Allocation("Allocation_" + id), new Flag("Flag_" + id), new Variant("Variant_" + id), - new Subject("Subject_" + id, singletonMap("key_" + id, (Object) ("value_" + id)))); + new Subject("Subject_" + id, attributes)); } } From 6cf876e9b08b0f46b424c68c81733ea1e883ea8a Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 5 Aug 2026 22:27:43 +0000 Subject: [PATCH 09/11] Exercise null Instant adapter path --- .../featureflag/RemoteConfigServiceImplTest.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 8e43497da2a..89bff466810 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 @@ -321,11 +321,20 @@ void flagMapAdapterIsReadOnly() { }) void testInstantParsing(final String value, final String expectedInstant) throws Exception { final JsonReader reader = mock(JsonReader.class); - when(reader.nextString()).thenReturn(value); + if (value == null) { + when(reader.peek()).thenReturn(JsonReader.Token.NULL); + when(reader.nextNull()).thenReturn(null); + } else { + when(reader.peek()).thenReturn(JsonReader.Token.STRING); + when(reader.nextString()).thenReturn(value); + } final UniversalFlagConfigParser.InstantAdapter adapter = new UniversalFlagConfigParser.InstantAdapter(); final Instant parsed = adapter.fromJson(reader); + if (value == null) { + verify(reader).nextNull(); + } if (expectedInstant == null) { assertNull(parsed); } else { From 66f6a7a056cdf03af51d34f10ca7d6dd45f4cb1c Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 6 Aug 2026 02:15:00 +0000 Subject: [PATCH 10/11] Cover FFE allocation parser branches Exercise the allocation adapter factory, null, and read-only paths required by changed-code coverage, and remove an unreachable parser null guard. Environment: Datadog workspace --- .../UniversalFlagConfigParser.java | 5 +--- .../RemoteConfigServiceImplTest.java | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java index f98aa11e008..8951f20f866 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java @@ -200,10 +200,7 @@ static final class AllocationJson { } @Nullable - private static Instant parseInstant(@Nullable final String date) { - if (date == null) { - return null; - } + private static Instant parseInstant(final String date) { try { return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); } catch (Exception e) { 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 89bff466810..6d14a28f796 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 @@ -298,6 +298,35 @@ void flagMapAdapterIsReadOnly() { () -> adapter.toJson(mock(JsonWriter.class), emptyMap())); } + @Test + void allocationAdapterFactoryOnlyCreatesAllocationAdapterForAllocationType() { + final Moshi moshi = moshi(); + + final JsonAdapter adapter = + UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( + Allocation.class, emptySet(), moshi); + + assertNotNull(adapter); + assertTrue(adapter instanceof UniversalFlagConfigParser.AllocationAdapter); + assertNull( + UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( + String.class, emptySet(), moshi)); + assertNull( + UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( + Allocation.class, singleton(mock(Annotation.class)), moshi)); + } + + @Test + void allocationAdapterHandlesNullAndIsReadOnly() throws Exception { + final UniversalFlagConfigParser.AllocationAdapter adapter = + new UniversalFlagConfigParser.AllocationAdapter( + moshi().adapter(UniversalFlagConfigParser.AllocationJson.class)); + + assertNull(adapter.fromJson("null")); + assertThrows( + UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), null)); + } + @TableTest({ "scenario | value | expectedInstant ", "utc second | '2023-01-01T00:00:00Z' | '2023-01-01T00:00:00Z' ", From 1e10c60fd7396d8a4ed3e8cb3d83eac430510069 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 7 Aug 2026 14:07:18 +0000 Subject: [PATCH 11/11] Respect exposure logging conventions Exclude serialization failures from telemetry and log submission failures at debug level. Environment: Datadog workspace --- .../java/com/datadog/featureflag/ExposureWriterImpl.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java index ab4af6938de..9932a20256b 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java @@ -1,5 +1,6 @@ package com.datadog.featureflag; +import static datadog.trace.api.telemetry.LogCollector.EXCLUDE_TELEMETRY; import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_EXPOSURE_PROCESSOR; import static datadog.trace.util.AgentThreadFactory.newAgentThread; import static java.util.concurrent.TimeUnit.SECONDS; @@ -183,7 +184,7 @@ protected void flushIfNecessary() { final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); requestBodyJson = jsonAdapter.toJson(exposures); } catch (RuntimeException e) { - LOGGER.error("Could not serialize exposures; dropping batch", e); + LOGGER.error(EXCLUDE_TELEMETRY, "Could not serialize exposures; dropping batch", e); this.buffer.clear(); return; } @@ -193,7 +194,7 @@ protected void flushIfNecessary() { evp.post("exposures", requestBody, stream -> null, null, false); this.buffer.clear(); } catch (Exception e) { - LOGGER.error("Could not submit exposures", e); + LOGGER.debug("Could not submit exposures", e); } } }