From b9aca431bba88f730502cd04acdbe11e56bcbe03 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 21 Jan 2026 19:35:06 -0500 Subject: [PATCH] [Feature Flags] Fix ISO 8601 date parsing to support variable precision The DateAdapter was using a DateTimeFormatter with a fixed [.SSS] pattern, which only supports optional 3-digit milliseconds. This caused dates with 6-digit microsecond precision (e.g., "2025-09-23T15:48:37.235982Z") sent by the backend to silently fail parsing (returning null). Changed to use Instant.parse() which correctly handles all valid ISO 8601 date formats including: - No fractional seconds: 2020-01-01T00:00:00Z - 1-9 digit fractional seconds (beyond ms precision truncated by Date) - UTC offsets: 2023-01-01T01:00:00+01:00 Added comprehensive unit tests for various date precisions and updated smoke test data with microsecond-date-test flag and test cases. --- .../src/test/resources/config/flags-v1.json | 55 +++++++++++++++++++ .../data/test-case-microsecond-date-flag.json | 54 ++++++++++++++++++ .../featureflag/RemoteConfigServiceImpl.java | 15 ++--- .../RemoteConfigServiceTest.groovy | 40 +++++++++----- 4 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 dd-smoke-tests/openfeature/src/test/resources/data/test-case-microsecond-date-flag.json diff --git a/dd-smoke-tests/openfeature/src/test/resources/config/flags-v1.json b/dd-smoke-tests/openfeature/src/test/resources/config/flags-v1.json index 8c7c1c29308..5b21a9f3661 100644 --- a/dd-smoke-tests/openfeature/src/test/resources/config/flags-v1.json +++ b/dd-smoke-tests/openfeature/src/test/resources/config/flags-v1.json @@ -2891,6 +2891,61 @@ "doLog": true } ] + }, + "microsecond-date-test": { + "key": "microsecond-date-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "expired": { + "key": "expired", + "value": "expired" + }, + "active": { + "key": "active", + "value": "active" + }, + "future": { + "key": "future", + "value": "future" + } + }, + "allocations": [ + { + "key": "expired-allocation", + "splits": [ + { + "variationKey": "expired", + "shards": [] + } + ], + "endAt": "2002-10-31T09:00:00.594321Z", + "doLog": true + }, + { + "key": "future-allocation", + "splits": [ + { + "variationKey": "future", + "shards": [] + } + ], + "startAt": "2052-10-31T09:00:00.123456Z", + "doLog": true + }, + { + "key": "active-allocation", + "splits": [ + { + "variationKey": "active", + "shards": [] + } + ], + "startAt": "2022-10-31T09:00:00.235982Z", + "endAt": "2050-10-31T09:00:00.987654Z", + "doLog": true + } + ] } } } diff --git a/dd-smoke-tests/openfeature/src/test/resources/data/test-case-microsecond-date-flag.json b/dd-smoke-tests/openfeature/src/test/resources/data/test-case-microsecond-date-flag.json new file mode 100644 index 00000000000..d363b260c42 --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/resources/data/test-case-microsecond-date-flag.json @@ -0,0 +1,54 @@ +[ + { + "flag": "microsecond-date-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "alice", + "attributes": {}, + "result": { + "value": "active", + "variant": "active", + "flagMetadata": { + "allocationKey": "active-allocation", + "variationType": "string", + "doLog": true + } + } + }, + { + "flag": "microsecond-date-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "bob", + "attributes": { + "country": "US" + }, + "result": { + "value": "active", + "variant": "active", + "flagMetadata": { + "allocationKey": "active-allocation", + "variationType": "string", + "doLog": true + } + } + }, + { + "flag": "microsecond-date-test", + "variationType": "STRING", + "defaultValue": "unknown", + "targetingKey": "charlie", + "attributes": { + "version": "1.0.0" + }, + "result": { + "value": "active", + "variant": "active", + "flagMetadata": { + "allocationKey": "active-allocation", + "variationType": "string", + "doLog": true + } + } + } +] diff --git a/products/feature-flagging/lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index 989d1ddbd63..cf0c400ccad 100644 --- a/products/feature-flagging/lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java +++ b/products/feature-flagging/lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java @@ -16,10 +16,7 @@ import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.time.Instant; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.time.temporal.TemporalAccessor; +import java.time.OffsetDateTime; import java.util.Date; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -76,9 +73,6 @@ public ServerConfiguration deserialize(final byte[] content) throws IOException static class DateAdapter extends JsonAdapter { - private static final DateTimeFormatter DATE_TIME_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss[.SSS]'Z'").withZone(ZoneOffset.UTC); - @Nullable @Override public Date fromJson(@Nonnull final JsonReader reader) throws IOException { @@ -87,9 +81,10 @@ public Date fromJson(@Nonnull final JsonReader reader) throws IOException { return null; } try { - final TemporalAccessor temporalAccessor = DATE_TIME_FORMATTER.parse(date); - final Instant instant = Instant.from(temporalAccessor); - return Date.from(instant); + // 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()); } catch (Exception e) { // ignore wrongly set dates return null; diff --git a/products/feature-flagging/lib/src/test/groovy/com/datadog/featureflag/RemoteConfigServiceTest.groovy b/products/feature-flagging/lib/src/test/groovy/com/datadog/featureflag/RemoteConfigServiceTest.groovy index 3545d75b191..7ae78dd61fc 100644 --- a/products/feature-flagging/lib/src/test/groovy/com/datadog/featureflag/RemoteConfigServiceTest.groovy +++ b/products/feature-flagging/lib/src/test/groovy/com/datadog/featureflag/RemoteConfigServiceTest.groovy @@ -87,24 +87,34 @@ class RemoteConfigServiceTest extends DDSpecification { date == expected where: - string | expected - // Valid ISO 8601 formats - "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 - "2023-01-01T00:00:00.000Z" | new Date(1672531200000L) // With milliseconds - "2023-06-15T14:30:45.123Z" | new Date(1686839445123L) // With milliseconds + 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-01T01:00:00+01:00" | null // UTC+1 - "2023-01-01T00:00:00-05:00" | null // UTC-5 - "2023-01-01" | null // Date only - "invalid-date" | null - "" | null - "not-a-date" | null - "2023/01/01T00:00:00Z" | null // Wrong separator + "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 + null | null } void 'test parsing only adapter'() {