diff --git a/.github/workflows/run-system-tests.yaml b/.github/workflows/run-system-tests.yaml index 4b0ad663659..dbc99e4a1e2 100644 --- a/.github/workflows/run-system-tests.yaml +++ b/.github/workflows/run-system-tests.yaml @@ -62,7 +62,7 @@ jobs: needs: - build # If you change the following comment, update the pattern in the update_system_test_reference.sh script to match. - uses: DataDog/system-tests/.github/workflows/system-tests.yml@main # system tests are pinned on release branches only + uses: DataDog/system-tests/.github/workflows/system-tests.yml@20ec39676051d9eedf2fdcea17f716e7b89d3c10 # system tests are pinned on release branches only permissions: contents: read id-token: write @@ -70,7 +70,7 @@ jobs: with: library: java # If you change the following comment, update the pattern in the update_system_test_reference.sh script to match. - ref: "main" # system tests are pinned on release branches only + ref: "20ec39676051d9eedf2fdcea17f716e7b89d3c10" # system tests are pinned on release branches only binaries_artifact: binaries desired_execution_time: 900 # 15 minutes scenarios_groups: tracer-release diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fed9f25df92..df1aeb2708f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1475,7 +1475,7 @@ publishing-gate: configure_system_tests: variables: - SYSTEM_TESTS_REF: "main" # system tests are pinned on release branches only + SYSTEM_TESTS_REF: "20ec39676051d9eedf2fdcea17f716e7b89d3c10" # system tests are pinned on release branches only SYSTEM_TESTS_SCENARIOS_GROUPS: "simple_onboarding,simple_onboarding_profiling,simple_onboarding_appsec,docker-ssi,lib-injection" create_key: diff --git a/.gitlab/one-pipeline.locked.yml b/.gitlab/one-pipeline.locked.yml index e3ec0428cc7..08c4ee68544 100644 --- a/.gitlab/one-pipeline.locked.yml +++ b/.gitlab/one-pipeline.locked.yml @@ -1,4 +1,4 @@ # DO NOT EDIT THIS FILE MANUALLY # This file is auto-generated by automation. include: - - remote: https://gitlab-templates.ddbuild.io/libdatadog/include/versions/1.1.0/one-pipeline.yml + - remote: https://gitlab-templates.ddbuild.io/libdatadog/include/versions/1.2.0/one-pipeline.yml diff --git a/dd-java-agent/ddprof-lib/gradle.lockfile b/dd-java-agent/ddprof-lib/gradle.lockfile index 3200a914dfc..d5bceac668b 100644 --- a/dd-java-agent/ddprof-lib/gradle.lockfile +++ b/dd-java-agent/ddprof-lib/gradle.lockfile @@ -5,7 +5,7 @@ ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath com.datadoghq:dd-javac-plugin-client:0.2.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.datadoghq:ddprof:1.48.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:ddprof:1.49.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.javaparser:javaparser-core:3.25.6=codenarc com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,spotbugs com.github.spotbugs:spotbugs:4.9.8=spotbugs diff --git a/dd-java-agent/instrumentation/kafka/kafka-connect-0.11/build.gradle b/dd-java-agent/instrumentation/kafka/kafka-connect-0.11/build.gradle index 57745940c19..f5540785beb 100644 --- a/dd-java-agent/instrumentation/kafka/kafka-connect-0.11/build.gradle +++ b/dd-java-agent/instrumentation/kafka/kafka-connect-0.11/build.gradle @@ -17,30 +17,40 @@ muzzle { '7.5.14-ccs', '7.5.15-ce', '7.5.15-ccs', + '7.5.16-ce', + '7.5.16-ccs', '7.6.10-ce', '7.6.10-ccs', '7.6.11-ce', '7.6.11-ccs', '7.6.12-ce', '7.6.12-ccs', + '7.6.13-ce', + '7.6.13-ccs', '7.7.8-ce', '7.7.8-ccs', '7.7.9-ce', '7.7.9-ccs', '7.7.10-ce', '7.7.10-ccs', + '7.7.11-ce', + '7.7.11-ccs', '7.8.7-ce', '7.8.7-ccs', '7.8.8-ce', '7.8.8-ccs', '7.8.9-ce', '7.8.9-ccs', + '7.8.10-ce', + '7.8.10-ccs', '7.9.6-ce', '7.9.6-ccs', '7.9.7-ce', '7.9.7-ccs', '7.9.8-ce', - '7.9.8-ccs' + '7.9.8-ccs', + '7.9.9-ce', + '7.9.9-ccs' ] excludeDependency "io.confluent.cloud:*" excludeDependency "io.confluent.observability:*" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b7ec5949343..ad9f39d0740 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ shadow = "9.4.2" spotbugs_annotations = "4.9.8" # DataDog libs and forks -ddprof = "1.48.1" +ddprof = "1.49.0" dogstatsd = "4.4.5" okhttp = "3.12.15" # Datadog fork to support Java 7 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 6d55c54a5b3..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 @@ -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; @@ -125,7 +125,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) { @@ -208,14 +208,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)) { + static boolean isAllocationActive(final Allocation allocation, final Instant now) { + final Instant startDate = allocation.startAtInstant(); + if (startDate != null && now.isBefore(startDate)) { return false; } - final Date endDate = allocation.endAt; - if (endDate != null && now.after(endDate)) { + final Instant endDate = allocation.endAtInstant(); + if (endDate != null && now.isAfter(endDate)) { return false; } @@ -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 7b6cc01e021..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 @@ -24,6 +24,7 @@ import com.squareup.moshi.Moshi; import com.squareup.moshi.Types; 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 dev.openfeature.sdk.ErrorCode; @@ -37,7 +38,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.time.OffsetDateTime; +import java.time.Instant; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; @@ -123,7 +125,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), }; } @@ -212,6 +214,33 @@ 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())); + } + + @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())); @@ -227,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]); } @@ -410,7 +441,8 @@ public Date fromJson(final JsonReader reader) throws IOException { return reader.nextNull(); } try { - return Date.from(OffsetDateTime.parse(reader.nextString()).toInstant()); + return Date.from( + DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(reader.nextString(), Instant::from)); } catch (final Exception ignored) { return null; } 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..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 @@ -1,5 +1,6 @@ package datadog.trace.api.featureflag.ufc.v1; +import java.time.Instant; import java.util.Date; import java.util.List; @@ -11,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, @@ -18,11 +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 preciseStartAt != null ? preciseStartAt : startAt == null ? null : startAt.toInstant(); + } + + public Instant endAtInstant() { + return preciseEndAt != null ? preciseEndAt : endAt == null ? null : endAt.toInstant(); } } 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..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; @@ -178,15 +179,22 @@ 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(EXCLUDE_TELEMETRY, "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) { - LOGGER.error("Could not submit exposures", e); + LOGGER.debug("Could not submit exposures", e); } } } 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 ba5536601f7..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 @@ -7,29 +7,40 @@ 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; import javax.annotation.Nullable; import okio.BufferedSource; import okio.Okio; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; final class UniversalFlagConfigParser implements ConfigurationDeserializer { + private static final Logger LOGGER = LoggerFactory.getLogger(UniversalFlagConfigParser.class); + static final UniversalFlagConfigParser INSTANCE = new UniversalFlagConfigParser(); 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(AllocationAdapter.FACTORY) + .add(FlagMapAdapter.FACTORY) + .build(); private static final JsonAdapter V1_ADAPTER = MOSHI.adapter(ServerConfiguration.class); @@ -97,8 +108,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(); @@ -112,28 +126,86 @@ 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 { - final String date = reader.nextString(); - if (date == null) { - return null; + public Instant fromJson(@Nonnull final JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); } - try { - final Instant instant = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); - return Date.from(instant); - } 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(final String date) { + 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/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)); } } 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 c1f5ef17b87..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 @@ -29,6 +29,7 @@ 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.io.IOException; @@ -138,6 +139,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.123456+01:00\"," + + "\"endAt\":\"2023-01-02T00:00:00.987654Z\"," + + "\"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: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 void rejectsTrailingJson() { assertThrows(IOException.class, () -> deserialize(emptyConfig() + "{}")); @@ -262,50 +298,88 @@ 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 | 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 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 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 UniversalFlagConfigParser.DateAdapter adapter = - new UniversalFlagConfigParser.DateAdapter(); + 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 Date parsed = adapter.fromJson(reader); - if (expectedEpochMilli == null) { + final Instant parsed = adapter.fromJson(reader); + if (value == null) { + verify(reader).nextNull(); + } + if (expectedInstant == null) { assertNull(parsed); } else { assertNotNull(parsed); - assertEquals(Instant.ofEpochMilli(expectedEpochMilli), parsed.toInstant()); + 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), new Date())); + () -> adapter.toJson(mock(JsonWriter.class), Instant.EPOCH)); } @SuppressWarnings("unchecked") @@ -318,7 +392,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() {