Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -0,0 +1,188 @@
package datadog.trace.api.openfeature;

import static java.util.concurrent.TimeUnit.NANOSECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;

import datadog.trace.api.featureflag.FeatureFlaggingGateway;
import datadog.trace.api.featureflag.exposure.ExposureEvent;
import dev.openfeature.sdk.ImmutableMetadata;
import dev.openfeature.sdk.ImmutableStructure;
import dev.openfeature.sdk.MutableContext;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.Reason;
import dev.openfeature.sdk.Value;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
import org.openjdk.jmh.annotations.Warmup;

/**
* Measures the evaluation-thread cost of exposure admission and context capture.
*
* <p>The first path always captures an event. The duplicate path uses the same scalar identity for
* every invocation. This separates the required first-event copy from avoidable duplicate copies.
*
* <p>Run: {@code ./gradlew :products:feature-flagging:feature-flagging-api:jmh
* -PjmhIncludes=ExposureDispatchHotPathBenchmark -PjmhProf=gc}.
*/
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 2, timeUnit = SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = SECONDS)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(NANOSECONDS)
@Fork(1)
public class ExposureDispatchHotPathBenchmark {

@Param({"empty", "flat/100attrs", "nested/10structs_10fields", "wide/1000attrs"})
public String shape;

@Param({"first", "duplicate"})
public String path;

private MutableContext context;
private ProviderEvaluation<String> evaluation;
private BenchmarkExposureListener listener;

@Setup(Level.Trial)
public void setUp() {
context = buildContext(shape);
evaluation =
ProviderEvaluation.<String>builder()
.value("on-value")
.variant("on")
.reason(Reason.TARGETING_MATCH.name())
.flagMetadata(ImmutableMetadata.builder().addString("allocationKey", "alloc-1").build())
.build();
listener = new BenchmarkExposureListener("first".equals(path));
if ("duplicate".equals(path)) {
listener.record("bench-flag", "bench-user", "on", "alloc-1");
}
FeatureFlaggingGateway.addExposureListener(listener);
}

@TearDown(Level.Trial)
public void tearDown() {
FeatureFlaggingGateway.removeExposureListener(listener);
}

@Benchmark
public void dispatchExposure() {
DDEvaluator.dispatchExposure("bench-flag", evaluation, context);
}

private static MutableContext buildContext(final String shape) {
final MutableContext ctx = new MutableContext("bench-user");
if ("empty".equals(shape)) {
return ctx;
}
if ("flat/100attrs".equals(shape)) {
return addFlat(ctx, 100);
}
if ("nested/10structs_10fields".equals(shape)) {
for (int i = 0; i < 10; i++) {
final Map<String, Value> inner = new HashMap<>();
for (int j = 0; j < 10; j++) {
inner.put("field" + j, new Value("value" + j));
}
ctx.add("struct" + i, new ImmutableStructure(inner));
}
return ctx;
}
if ("wide/1000attrs".equals(shape)) {
return addFlat(ctx, 1_000);
}
throw new IllegalArgumentException("unknown benchmark shape: " + shape);
}

private static MutableContext addFlat(final MutableContext ctx, final int count) {
for (int i = 0; i < count; i++) {
ctx.add("field" + i, "value" + i);
}
return ctx;
}

private static final class BenchmarkExposureListener
implements FeatureFlaggingGateway.ExposureListener {
private final boolean alwaysCapture;
private final ConcurrentMap<Identity, IdentityValue> identities = new ConcurrentHashMap<>();

private BenchmarkExposureListener(final boolean alwaysCapture) {
this.alwaysCapture = alwaysCapture;
}

@Override
public boolean shouldCapture(
final String flag, final String subject, final String variant, final String allocation) {
final IdentityValue current = identities.get(new Identity(flag, subject));
if (alwaysCapture) {
return true;
}
return current == null || !current.matches(variant, allocation);
}

@Override
public void accept(final ExposureEvent event) {
record(event.flag.key, event.subject.id, event.variant.key, event.allocation.key);
}

private void record(
final String flag, final String subject, final String variant, final String allocation) {
identities.put(new Identity(flag, subject), new IdentityValue(variant, allocation));
}
}

private static final class Identity {
private final String flag;
private final String subject;

private Identity(final String flag, final String subject) {
this.flag = flag;
this.subject = subject;
}

@Override
public boolean equals(final Object other) {
if (this == other) {
return true;
}
if (!(other instanceof Identity)) {
return false;
}
final Identity identity = (Identity) other;
return Objects.equals(flag, identity.flag) && Objects.equals(subject, identity.subject);
}

@Override
public int hashCode() {
return Objects.hash(flag, subject);
}
}

private static final class IdentityValue {
private final String variant;
private final String allocation;

private IdentityValue(final String variant, final String allocation) {
this.variant = variant;
this.allocation = allocation;
}

private boolean matches(final String otherVariant, final String otherAllocation) {
return Objects.equals(variant, otherVariant) && Objects.equals(allocation, otherAllocation);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.ImmutableMetadata;
import dev.openfeature.sdk.ImmutableStructure;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.Reason;
import dev.openfeature.sdk.Structure;
Expand All @@ -29,14 +28,10 @@
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
Expand All @@ -57,8 +52,7 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
* caller's evaluation thread over a caller-owned Value tree, so an arbitrarily deep
* list/structure would overflow that thread's stack - and a StackOverflowError is not caught by
* the LinkageError | Exception guards that keep telemetry from breaking an evaluation. Values
* below the limit are truncated to null, the same way the cycle guard truncates. Kept aligned
* with the cross-SDK RFC target (4).
* below the limit are omitted. Kept aligned with the cross-SDK RFC target (4).
*/
static final int MAX_SNAPSHOT_DEPTH = 4;

Expand Down Expand Up @@ -649,20 +643,26 @@ private static Double parseDouble(final Object value) {
return Double.parseDouble(String.valueOf(value));
}

private static <T> void dispatchExposure(
static <T> void dispatchExposure(
final String flag, final ProviderEvaluation<T> evaluation, final EvaluationContext context) {
final String allocationKey = allocationKey(evaluation);
final String variantKey = evaluation.getVariant();
if (allocationKey == null || variantKey == null) {
return;
}
final String subjectKey = context.getTargetingKey();
if (!FeatureFlaggingGateway.shouldCaptureExposure(
flag, subjectKey, variantKey, allocationKey)) {
return;
}
final Map<String, Object> attributes = copyExposureContext(context).attrs;
final ExposureEvent event =
new ExposureEvent(
System.currentTimeMillis(),
new datadog.trace.api.featureflag.exposure.Allocation(allocationKey),
new datadog.trace.api.featureflag.exposure.Flag(flag),
new datadog.trace.api.featureflag.exposure.Variant(variantKey),
new Subject(context.getTargetingKey(), flattenContext(context)));
new Subject(subjectKey, attributes));

FeatureFlaggingGateway.dispatch(event);
}
Expand All @@ -672,96 +672,6 @@ private static <T> String allocationKey(final ProviderEvaluation<T> resolution)
return meta == null ? null : meta.getString("allocationKey");
}

static AbstractMap<String, Object> flattenContext(final EvaluationContext context) {
return flattenValues(snapshotValues(context));
}

static Map<String, Value> snapshotValues(final EvaluationContext context) {
final HashMap<String, Value> values = new HashMap<>();
final Set<Object> seenContainers = Collections.newSetFromMap(new IdentityHashMap<>());
for (final String key : context.keySet()) {
values.put(key, snapshotValue(context.getValue(key), seenContainers, 0));
}
return values;
}

private static Value snapshotValue(
final Value value, final Set<Object> seenContainers, final int depth) {
if (value == null) {
return null;
} else if (value.isNull()) {
return new Value();
} else if (value.isBoolean()) {
return new Value(value.asBoolean());
} else if (value.isNumber()) {
final Object number = value.asObject();
return number instanceof Integer
? new Value((Integer) number)
: new Value(((Number) number).doubleValue());
} else if (value.isString()) {
return new Value(value.asString());
} else if (value.isInstant()) {
return new Value(value.asInstant());
} else if (value.isList()) {
final List<Value> list = value.asList();
if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(list)) {
return new Value();
}
final List<Value> snapshot = new ArrayList<>(list.size());
for (final Value item : list) {
snapshot.add(snapshotValue(item, seenContainers, depth + 1));
}
seenContainers.remove(list);
return new Value(Collections.unmodifiableList(snapshot));
} else if (value.isStructure()) {
final Structure structure = value.asStructure();
if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(structure)) {
return new Value();
}
final Map<String, Value> snapshot = new HashMap<>();
for (final String key : structure.keySet()) {
snapshot.put(key, snapshotValue(structure.getValue(key), seenContainers, depth + 1));
}
seenContainers.remove(structure);
return new Value(new ImmutableStructure(snapshot));
}
throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value);
}

static AbstractMap<String, Object> flattenValues(final Map<String, Value> values) {
final HashMap<String, Object> result = new HashMap<>();
final Set<Object> seenContainers = Collections.newSetFromMap(new IdentityHashMap<>());
for (final Map.Entry<String, Value> root : values.entrySet()) {
final Deque<FlattenEntry> deque = new LinkedList<>();
deque.push(new FlattenEntry(root.getKey(), root.getValue()));
while (!deque.isEmpty()) {
final FlattenEntry entry = deque.pop();
final Value value = entry.value;
if (value == null) {
result.put(entry.key, null);
} else if (value.isList()) {
final List<Value> list = value.asList();
if (seenContainers.add(list)) {
for (int i = 0; i < list.size(); i++) {
deque.push(new FlattenEntry(entry.key + "[" + i + "]", list.get(i)));
}
}
} else if (value.isStructure()) {
final Structure structure = value.asStructure();
if (seenContainers.add(structure)) {
for (final String property : structure.keySet()) {
deque.push(
new FlattenEntry(entry.key + "." + property, structure.getValue(property)));
}
}
} else {
result.put(entry.key, convertValue(value));
}
}
}
return result;
}

private static Object convertValue(final Value value) {
if (value == null || value.isNull()) {
return null;
Expand Down Expand Up @@ -856,6 +766,19 @@ static final class CopyResult {
* canonical-key sorting happens once in the aggregator, off the hot path.
*/
static CopyResult copyPrunedContext(final EvaluationContext context) {
return copyPrunedContext(context, false);
}

/**
* Builds the bounded exposure attributes. The targeting key stays in the attributes to preserve
* the existing exposure payload. The subject ID also carries the targeting key.
*/
static CopyResult copyExposureContext(final EvaluationContext context) {
return copyPrunedContext(context, true);
}

private static CopyResult copyPrunedContext(
final EvaluationContext context, final boolean includeTargetingKey) {
if (context == null) {
return new CopyResult(Collections.emptyMap(), null);
}
Expand All @@ -871,7 +794,7 @@ static CopyResult copyPrunedContext(final EvaluationContext context) {
reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS;
break;
}
if (EvaluationContext.TARGETING_KEY.equals(key)) {
if (!includeTargetingKey && EvaluationContext.TARGETING_KEY.equals(key)) {
continue;
}
copyPrunedValue(out, key, context.getValue(key), seen, 0, reasonMask);
Expand Down Expand Up @@ -973,14 +896,4 @@ private interface NumberComparator {
private interface SemverComparator {
boolean compare(int ordering);
}

private static class FlattenEntry {
private final String key;
private final Value value;

private FlattenEntry(final String key, final Value value) {
this.key = key;
this.value = value;
}
}
}
Loading
Loading