Skip to content

Commit c341906

Browse files
Add evaluation logic
1 parent e935d21 commit c341906

File tree

4 files changed

+371
-17
lines changed

4 files changed

+371
-17
lines changed

dd-java-agent/agent-featureflag/src/main/java/com/datadog/featureflag/FeatureFlagEvaluatorImpl.java

Lines changed: 325 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
11
package com.datadog.featureflag;
22

3+
import com.datadog.featureflag.ufc.v1.Allocation;
4+
import com.datadog.featureflag.ufc.v1.ConditionConfiguration;
5+
import com.datadog.featureflag.ufc.v1.ConditionOperator;
6+
import com.datadog.featureflag.ufc.v1.Flag;
7+
import com.datadog.featureflag.ufc.v1.Rule;
38
import com.datadog.featureflag.ufc.v1.ServerConfiguration;
9+
import com.datadog.featureflag.ufc.v1.Shard;
10+
import com.datadog.featureflag.ufc.v1.ShardRange;
11+
import com.datadog.featureflag.ufc.v1.Split;
12+
import com.datadog.featureflag.ufc.v1.Variant;
413
import datadog.trace.api.featureflag.FeatureFlagEvaluator;
14+
import java.nio.charset.StandardCharsets;
15+
import java.security.MessageDigest;
16+
import java.security.NoSuchAlgorithmException;
17+
import java.text.ParseException;
18+
import java.text.SimpleDateFormat;
19+
import java.util.Collection;
520
import java.util.Collections;
21+
import java.util.Date;
22+
import java.util.HashMap;
23+
import java.util.List;
24+
import java.util.Map;
625
import java.util.Set;
26+
import java.util.TimeZone;
727
import java.util.WeakHashMap;
828
import java.util.concurrent.atomic.AtomicReference;
929
import java.util.function.Consumer;
30+
import java.util.regex.Pattern;
1031

1132
public class FeatureFlagEvaluatorImpl
1233
implements FeatureFlagEvaluator, Consumer<ServerConfiguration> {
@@ -17,36 +38,330 @@ public class FeatureFlagEvaluatorImpl
1738
@Override
1839
public Resolution<Boolean> evaluate(
1940
final String key, final Boolean defaultValue, final Context context) {
20-
// TODO
21-
throw new UnsupportedOperationException();
41+
return evaluateInternal(key, defaultValue, context);
2242
}
2343

2444
@Override
2545
public Resolution<Integer> evaluate(
2646
final String key, final Integer defaultValue, final Context context) {
27-
// TODO
28-
throw new UnsupportedOperationException();
47+
return evaluateInternal(key, defaultValue, context);
2948
}
3049

3150
@Override
3251
public Resolution<Double> evaluate(
3352
final String key, final Double defaultValue, final Context context) {
34-
// TODO
35-
throw new UnsupportedOperationException();
53+
return evaluateInternal(key, defaultValue, context);
3654
}
3755

3856
@Override
3957
public Resolution<String> evaluate(
4058
final String key, final String defaultValue, final Context context) {
41-
// TODO
42-
throw new UnsupportedOperationException();
59+
return evaluateInternal(key, defaultValue, context);
4360
}
4461

4562
@Override
4663
public Resolution<Object> evaluate(
4764
final String key, final Object defaultValue, final Context context) {
48-
// TODO
49-
throw new UnsupportedOperationException();
65+
return evaluateInternal(key, defaultValue, context);
66+
}
67+
68+
private <T> Resolution<T> evaluateInternal(
69+
final String key, final T defaultValue, final Context context) {
70+
final ServerConfiguration config = configuration.get();
71+
72+
if (config == null) {
73+
// TODO: log warning and telemetry
74+
return Resolution.notInitialized(defaultValue);
75+
}
76+
77+
if (context == null || context.getTargetingKey() == null) {
78+
// TODO: log error and telemetry
79+
return Resolution.error(defaultValue);
80+
}
81+
82+
final Flag flag = config.flags.get(key);
83+
if (flag == null) {
84+
// TODO: log warning and telemetry
85+
return Resolution.defaultResolution(defaultValue);
86+
}
87+
88+
if (!flag.enabled) {
89+
// TODO: log warning and telemetry
90+
return new Resolution<>(defaultValue).setReason(ResolutionReason.DISABLED);
91+
}
92+
93+
if (flag.allocations == null || flag.allocations.isEmpty()) {
94+
// TODO: log warning and telemetry
95+
return Resolution.defaultResolution(defaultValue);
96+
}
97+
98+
final Date now = new Date();
99+
final String targetingKey = context.getTargetingKey();
100+
101+
for (final Allocation allocation : flag.allocations) {
102+
if (!isAllocationActive(allocation, now)) {
103+
continue;
104+
}
105+
106+
if (allocation.rules != null && !allocation.rules.isEmpty()) {
107+
if (!evaluateRules(allocation.rules, context)) {
108+
continue;
109+
}
110+
}
111+
112+
if (allocation.splits != null) {
113+
for (final Split split : allocation.splits) {
114+
if (split.shards != null) {
115+
if (split.shards.isEmpty()) {
116+
return resolveVariant(flag, split.variationKey, allocation);
117+
}
118+
119+
// To match a split, subject must match ALL underlying shards
120+
boolean allShardsMatch = true;
121+
for (final Shard shard : split.shards) {
122+
if (!matchesShard(shard, targetingKey)) {
123+
allShardsMatch = false;
124+
break;
125+
}
126+
}
127+
if (allShardsMatch) {
128+
return resolveVariant(flag, split.variationKey, allocation);
129+
}
130+
}
131+
}
132+
}
133+
}
134+
135+
return Resolution.defaultResolution(defaultValue);
136+
}
137+
138+
private boolean isAllocationActive(final Allocation allocation, final Date now) {
139+
if (allocation.startAt != null) {
140+
final Date startDate = parseDate(allocation.startAt);
141+
if (startDate != null && now.before(startDate)) {
142+
return false;
143+
}
144+
}
145+
146+
if (allocation.endAt != null) {
147+
final Date endDate = parseDate(allocation.endAt);
148+
if (endDate != null && now.after(endDate)) {
149+
return false;
150+
}
151+
}
152+
153+
return true;
154+
}
155+
156+
private boolean evaluateRules(final List<Rule> rules, final Context context) {
157+
for (final Rule rule : rules) {
158+
if (rule.conditions == null || rule.conditions.isEmpty()) {
159+
continue;
160+
}
161+
162+
boolean allConditionsMatch = true;
163+
for (final ConditionConfiguration condition : rule.conditions) {
164+
if (!evaluateCondition(condition, context)) {
165+
allConditionsMatch = false;
166+
break;
167+
}
168+
}
169+
170+
if (allConditionsMatch) {
171+
return true;
172+
}
173+
}
174+
return false;
175+
}
176+
177+
private boolean evaluateCondition(final ConditionConfiguration condition, final Context context) {
178+
if (condition.operator == ConditionOperator.IS_NULL) {
179+
final Object value = resolveAttribute(condition.attribute, context);
180+
boolean isNull = value == null;
181+
// condition.value determines if we're checking for null (true) or not null (false)
182+
boolean expectedNull = condition.value instanceof Boolean ? (Boolean) condition.value : true;
183+
return isNull == expectedNull;
184+
}
185+
186+
final Object attributeValue = resolveAttribute(condition.attribute, context);
187+
if (attributeValue == null) {
188+
return false;
189+
}
190+
191+
switch (condition.operator) {
192+
case MATCHES:
193+
return matchesRegex(attributeValue, condition.value);
194+
case NOT_MATCHES:
195+
return !matchesRegex(attributeValue, condition.value);
196+
case ONE_OF:
197+
return isOneOf(attributeValue, condition.value);
198+
case NOT_ONE_OF:
199+
return !isOneOf(attributeValue, condition.value);
200+
case GTE:
201+
return compareNumber(attributeValue, condition.value, (a, b) -> a >= b);
202+
case GT:
203+
return compareNumber(attributeValue, condition.value, (a, b) -> a > b);
204+
case LTE:
205+
return compareNumber(attributeValue, condition.value, (a, b) -> a <= b);
206+
case LT:
207+
return compareNumber(attributeValue, condition.value, (a, b) -> a < b);
208+
default:
209+
return false;
210+
}
211+
}
212+
213+
private boolean matchesRegex(final Object attributeValue, final Object conditionValue) {
214+
if (!(conditionValue instanceof String)) {
215+
return false;
216+
}
217+
try {
218+
final Pattern pattern = Pattern.compile((String) conditionValue);
219+
return pattern.matcher(String.valueOf(attributeValue)).find();
220+
} catch (Exception e) {
221+
return false;
222+
}
223+
}
224+
225+
private boolean isOneOf(final Object attributeValue, final Object conditionValue) {
226+
if (!(conditionValue instanceof Collection)) {
227+
return false;
228+
}
229+
final Collection<?> values = (Collection<?>) conditionValue;
230+
for (final Object value : values) {
231+
if (valuesEqual(attributeValue, value)) {
232+
return true;
233+
}
234+
}
235+
return false;
236+
}
237+
238+
private boolean valuesEqual(final Object a, final Object b) {
239+
if (a == null && b == null) {
240+
return true;
241+
}
242+
if (a == null || b == null) {
243+
return false;
244+
}
245+
246+
if (a instanceof Number || b instanceof Number) {
247+
return numbersEqual(a, b);
248+
}
249+
250+
return String.valueOf(a).equals(String.valueOf(b));
251+
}
252+
253+
private boolean numbersEqual(final Object a, final Object b) {
254+
try {
255+
return toDouble(a) == toDouble(b);
256+
} catch (Exception e) {
257+
return String.valueOf(a).equals(String.valueOf(b));
258+
}
259+
}
260+
261+
private boolean compareNumber(
262+
final Object attributeValue, final Object conditionValue, NumberComparator comparator) {
263+
try {
264+
final double a = toDouble(attributeValue);
265+
final double b = toDouble(conditionValue);
266+
return comparator.compare(a, b);
267+
} catch (Exception e) {
268+
return false;
269+
}
270+
}
271+
272+
private double toDouble(final Object value) {
273+
if (value instanceof Number) {
274+
return ((Number) value).doubleValue();
275+
}
276+
return Double.parseDouble(String.valueOf(value));
277+
}
278+
279+
private boolean matchesShard(final Shard shard, final String targetingKey) {
280+
final int assignedShard = getShard(shard.salt, targetingKey, shard.totalShards);
281+
for (final ShardRange range : shard.ranges) {
282+
if (assignedShard >= range.start && assignedShard < range.end) {
283+
return true;
284+
}
285+
}
286+
return false;
287+
}
288+
289+
private int getShard(final String salt, final String targetingKey, final int totalShards) {
290+
final String hashKey = salt + "-" + targetingKey;
291+
final String md5Hash = getMD5Hash(hashKey);
292+
final String first8Chars = md5Hash.substring(0, Math.min(8, md5Hash.length()));
293+
final long intFromHash = Long.parseLong(first8Chars, 16);
294+
return (int) (intFromHash % totalShards);
295+
}
296+
297+
private String getMD5Hash(final String input) {
298+
try {
299+
final MessageDigest md = MessageDigest.getInstance("MD5");
300+
final byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8));
301+
final StringBuilder hexString = new StringBuilder();
302+
for (byte b : hashBytes) {
303+
final String hex = Integer.toHexString(0xff & b);
304+
if (hex.length() == 1) {
305+
hexString.append('0');
306+
}
307+
hexString.append(hex);
308+
}
309+
return hexString.toString();
310+
} catch (NoSuchAlgorithmException e) {
311+
throw new RuntimeException("MD5 algorithm not available", e);
312+
}
313+
}
314+
315+
@SuppressWarnings("unchecked")
316+
private <T> Resolution<T> resolveVariant(
317+
final Flag flag, final String variationKey, final Allocation allocation) {
318+
final Variant variant = flag.variations.get(variationKey);
319+
if (variant == null) {
320+
return Resolution.error((T) null);
321+
}
322+
323+
final Map<String, Object> metadata = new HashMap<>();
324+
metadata.put("flagKey", flag.key);
325+
metadata.put("variationType", flag.variationType.name());
326+
if (allocation.doLog != null && allocation.doLog) {
327+
metadata.put("allocationKey", allocation.key);
328+
}
329+
330+
return Resolution.targetingMatch((T) variant.value)
331+
.setVariant(variant.key)
332+
.setFlagMetadata(metadata);
333+
}
334+
335+
private Date parseDate(final String dateString) {
336+
if (dateString == null) {
337+
return null;
338+
}
339+
try {
340+
final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
341+
sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
342+
return sdf.parse(dateString);
343+
} catch (ParseException e) {
344+
try {
345+
final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
346+
sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
347+
return sdf.parse(dateString);
348+
} catch (ParseException e2) {
349+
return null;
350+
}
351+
}
352+
}
353+
354+
@FunctionalInterface
355+
private interface NumberComparator {
356+
boolean compare(double a, double b);
357+
}
358+
359+
private Object resolveAttribute(final String name, final Context context) {
360+
// Special handling for "id" attribute: if not explicitly provided, use targeting key
361+
if ("id".equals(name) && !context.keySet().contains(name)) {
362+
return context.getTargetingKey();
363+
}
364+
return context.getValue(name);
50365
}
51366

52367
@Override

dd-java-agent/agent-featureflag/src/test/groovy/com/datadog/featureflag/evaluator/FeatureFlagEvaluatorTests.groovy

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import dev.openfeature.sdk.EvaluationContext
88
import dev.openfeature.sdk.FlagEvaluationDetails
99
import dev.openfeature.sdk.MutableContext
1010
import dev.openfeature.sdk.OpenFeatureAPI
11+
import dev.openfeature.sdk.Structure
1112
import dev.openfeature.sdk.Value
1213
import spock.lang.Shared
1314

@@ -44,8 +45,12 @@ class FeatureFlagEvaluatorTests extends BaseFeatureFlagsTest {
4445
protected static EvaluationContext buildContext(final TestCase testCase) {
4546
final context = new MutableContext().setTargetingKey(testCase.getTargetingKey())
4647
testCase.attributes?.each {
47-
if (it.value instanceof Map || it.value instanceof List) {
48-
context.add(it.key, Value.objectToValue(it.value))
48+
if (it.value instanceof Map) {
49+
context.add(it.key, Value.objectToValue(it.value).asStructure())
50+
} else if (it.value instanceof List) {
51+
context.add(it.key, Value.objectToValue(it.value).asList())
52+
} else if (it.value == null) {
53+
context.add(it.key, (Structure) null)
4954
} else {
5055
context.add(it.key, it.value)
5156
}

0 commit comments

Comments
 (0)