Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7772a57
Skip malformed FFE flags during config parsing
leoromanovsky Jun 2, 2026
2eaf305
Use Instant for FFE allocation windows
leoromanovsky Jun 2, 2026
d69f39b
Log dropped malformed FFE flags
leoromanovsky Jun 2, 2026
cb2e852
Merge remote-tracking branch 'origin/master' into leo.romanovsky/ffl-…
leoromanovsky Jun 2, 2026
d426669
Merge branch 'master' into leo.romanovsky/ffl-2339-instant-allocation…
leoromanovsky Jun 2, 2026
67712dd
Clarify FFE instant parser test label
leoromanovsky Jun 3, 2026
e738c43
Merge branch 'master' into leo.romanovsky/ffl-2339-instant-allocation…
leoromanovsky Jun 3, 2026
857be83
Merge remote-tracking branch 'origin/master' into leo.romanovsky/ffl-…
leoromanovsky Jul 2, 2026
b132ec0
Address FFE Instant review feedback
leoromanovsky Jul 7, 2026
193f983
Merge remote-tracking branch 'origin/master' into leo.romanovsky/ffl-…
leoromanovsky Jul 7, 2026
4d1c227
Preserve FFE allocation date compatibility
leoromanovsky Jul 7, 2026
cd1b2f6
Merge remote-tracking branch 'origin/master' into leo.romanovsky/ffl-…
leoromanovsky Jul 31, 2026
4cb00dc
Preserve microsecond precision in FFE allocation windows
leoromanovsky Aug 2, 2026
9faad87
Merge remote-tracking branch 'origin/master' into leo.romanovsky/ffl-…
leoromanovsky Aug 5, 2026
ad10c7c
Fix exposure context serialization
leoromanovsky Aug 5, 2026
6cf876e
Exercise null Instant adapter path
leoromanovsky Aug 5, 2026
66f6a7a
Cover FFE allocation parser branches
leoromanovsky Aug 6, 2026
1e10c60
Respect exposure logging conventions
leoromanovsky Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -125,7 +125,7 @@ public <T> ProviderEvaluation<T> 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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -546,7 +546,9 @@ static AbstractMap<String, Object> 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));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -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> arguments = new ArrayList<>();
arguments.add(Arguments.of(emptyMap(), emptyMap()));
Expand All @@ -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]);
}

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package datadog.trace.api.featureflag.ufc.v1;

import java.time.Instant;
import java.util.Date;
import java.util.List;

Expand All @@ -11,18 +12,69 @@ public class Allocation {
public final List<Split> splits;
public final Boolean doLog;

private final transient Instant preciseStartAt;
private final transient Instant preciseEndAt;

public Allocation(
final String key,
final List<Rule> rules,
final Date startAt,
final Date endAt,
final List<Split> 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<Rule> rules,
final Date startAt,
final Date endAt,
final List<Split> 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<Rule> rules,
final Instant startAt,
final Instant endAt,
final List<Split> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerConfiguration> {

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<ServerConfiguration> V1_ADAPTER =
MOSHI.adapter(ServerConfiguration.class);

Expand Down Expand Up @@ -97,8 +108,11 @@ public Map<String, Flag> 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());
Comment thread
leoromanovsky marked this conversation as resolved.
}
}
reader.endObject();
Expand All @@ -112,28 +126,86 @@ public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map<String,
}
}

static final class DateAdapter extends JsonAdapter<Date> {
static final class InstantAdapter extends JsonAdapter<Instant> {

@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<Allocation> {

static final Factory FACTORY =
new Factory() {
@Nullable
@Override
public JsonAdapter<?> create(
@Nonnull final Type type,
@Nonnull final Set<? extends Annotation> 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<AllocationJson> delegate;

AllocationAdapter(final JsonAdapter<AllocationJson> 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<Rule> rules;
Instant startAt;
Instant endAt;
List<Split> 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;
}
}
}
Loading
Loading