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
Expand Up @@ -181,7 +181,7 @@ public void testNoAllocations() {
flags.put("null-allocation", new Flag("target", true, null, null, null));
flags.put("empty-allocation", new Flag("target", true, null, null, emptyList()));
final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class));
evaluator.accept(new ServerConfiguration("", "", null, flags));
evaluator.accept(new ServerConfiguration("", "", false, null, flags));

final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ public static boolean isFlagEvaluationEnqueueEnabled() {
return flagEvalEnqueueEnabled;
}

/**
* Returns whether the currently active UFC environment has {@code observeFullEvaluationData}
* enabled. {@code false} (privacy-preserving default) when no UFC has been dispatched yet or when
* the field was absent/false on the last dispatched configuration.
*/
public static boolean isObserveFullEvaluationDataEnabled() {
final ServerConfiguration current = CURRENT_CONFIG.get();
return current != null && current.observeFullEvaluationData;
}

public static void addSpanEnrichmentListener(final SpanEnrichmentListener listener) {
SPAN_ENRICHMENT_LISTENERS.add(listener);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@
public class ServerConfiguration {
public final String createdAt;
public final String format;
public final boolean observeFullEvaluationData;
public final Environment environment;
public final Map<String, Flag> flags;

public ServerConfiguration(
final String createdAt,
final String format,
final boolean observeFullEvaluationData,
final Environment environment,
final Map<String, Flag> flags) {
this.createdAt = createdAt;
this.format = format;
this.observeFullEvaluationData = observeFullEvaluationData;
this.environment = environment;
this.flags = flags;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package datadog.trace.api.featureflag;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;

import datadog.trace.api.featureflag.exposure.ExposureEvent;
import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration;
import java.util.Collections;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -96,6 +99,25 @@ void testAttachingASpanEnrichmentListener() {
verifyNoMoreInteractions(spanEnrichmentListener);
}

@Test
void isObserveFullEvaluationDataEnabledDefaultsToFalseWithNoConfig() {
FeatureFlaggingGateway.dispatch((ServerConfiguration) null);
assertFalse(FeatureFlaggingGateway.isObserveFullEvaluationDataEnabled());
}

@Test
void isObserveFullEvaluationDataEnabledReflectsLastDispatchedConfig() {
final ServerConfiguration enabled =
new ServerConfiguration("", "", true, null, Collections.emptyMap());
FeatureFlaggingGateway.dispatch(enabled);
assertTrue(FeatureFlaggingGateway.isObserveFullEvaluationDataEnabled());

final ServerConfiguration disabled =
new ServerConfiguration("", "", false, null, Collections.emptyMap());
FeatureFlaggingGateway.dispatch(disabled);
assertFalse(FeatureFlaggingGateway.isObserveFullEvaluationDataEnabled());
}

private static void clearCurrentServerConfiguration() {
FeatureFlaggingGateway.dispatch((ServerConfiguration) null);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.datadog.featureflag;

import datadog.trace.api.featureflag.FeatureFlaggingGateway;
import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent;
import java.util.HashMap;
import java.util.Map;
Expand Down Expand Up @@ -44,13 +45,19 @@ final class FlagEvaluationAggregator {

void aggregate(final FlagEvalEvent event) {
final boolean isDefault = event.variant == null;
// Capture consent now, when the evaluation is folded into a bucket, so it reflects the
// configuration active at evaluation time rather than whatever CURRENT_CONFIG happens to be at
// flush. Existing buckets fold with AND: one no-consent evaluation sinks the bucket to hashed.
final boolean observeFullEvaluationData =
FeatureFlaggingGateway.isObserveFullEvaluationDataEnabled();
final Map<String, Object> prunedAttrs = pruneContext(event.contextAttributes());
final String ctxKey = canonicalContextKey(prunedAttrs);
final FullKey fullKey = buildFullKey(event, ctxKey);

EvalBucket bucket = fullTier.get(fullKey);
if (bucket != null) {
bucket.merge(event.evalTimeMs, isDefault);
bucket.observeFullEvaluationData &= observeFullEvaluationData;
return;
}

Expand All @@ -66,7 +73,8 @@ void aggregate(final FlagEvalEvent event) {
event.errorMessage,
event.evalTimeMs,
isDefault,
prunedAttrs));
prunedAttrs,
observeFullEvaluationData));
globalFullCount.incrementAndGet();
perFlagCount.put(event.flagKey, flagCount + 1);
return;
Expand All @@ -76,6 +84,7 @@ void aggregate(final FlagEvalEvent event) {
bucket = degradedTier.get(degradedKey);
if (bucket != null) {
bucket.merge(event.evalTimeMs, isDefault);
bucket.observeFullEvaluationData &= observeFullEvaluationData;
return;
}

Expand All @@ -90,7 +99,8 @@ void aggregate(final FlagEvalEvent event) {
event.errorMessage,
event.evalTimeMs,
isDefault,
null));
null,
observeFullEvaluationData));
return;
}

Expand Down Expand Up @@ -142,7 +152,7 @@ void simulateFullTierAtCap() {
final String key = "synthetic-full-" + i;
fullTier.put(
new FullKey(key, "on", "alloc", false, null, null, ""),
new EvalBucket(key, "on", "alloc", null, null, 1L, false, null));
new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false));
globalFullCount.incrementAndGet();
perFlagCount.merge(key, 1, Integer::sum);
}
Expand All @@ -153,7 +163,7 @@ void simulateDegradedTierAtCap() {
final String key = "synthetic-dg-" + i;
degradedTier.put(
new DegradedKey(key, "on", "alloc", false, null),
new EvalBucket(key, "on", "alloc", null, null, 1L, false, null));
new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false));
}
}

Expand All @@ -173,7 +183,8 @@ void addDegradedBucketForTest(
errorMessage,
evalTimeMs,
variant == null,
null));
null,
false));
}

private static FullKey buildFullKey(final FlagEvalEvent event, final String ctxKey) {
Expand Down Expand Up @@ -272,6 +283,12 @@ static class EvalBucket {
String targetingKey;
String errorMessage;
Map<String, Object> prunedAttrs;
// Consent to emit raw PII (targeting key + context) captured when this bucket was created, not
// read at flush time. CURRENT_CONFIG can be overwritten by a later RC update between capture
// and flush, so reading the gateway at flush would apply the wrong environment's consent. On
// merge the value is folded with AND (see aggregate()): if any evaluation in the bucket's
// lifetime saw consent off, the whole bucket falls back to hashed/omitted — fail-closed.
boolean observeFullEvaluationData;

EvalBucket(
final String flagKey,
Expand All @@ -281,7 +298,8 @@ static class EvalBucket {
final String errorMessage,
final long evalTimeMs,
final boolean runtimeDefaultUsed,
final Map<String, Object> prunedAttrs) {
final Map<String, Object> prunedAttrs,
final boolean observeFullEvaluationData) {
this.flagKey = flagKey;
this.variant = variant;
this.allocationKey = allocationKey;
Expand All @@ -292,6 +310,7 @@ static class EvalBucket {
this.count = 1;
this.runtimeDefaultUsed = runtimeDefaultUsed;
this.prunedAttrs = prunedAttrs;
this.observeFullEvaluationData = observeFullEvaluationData;
}

int prunedContextFieldCount() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ final class FlagEvaluationPayloads {

private static final byte[] PAYLOAD_SUFFIX = FeatureFlagEvpPublisher.utf8Bytes("]}");
private static final byte[] JSON_COMMA = FeatureFlagEvpPublisher.utf8Bytes(",");

/**
* Wire prefix identifying a privacy-preserving, hashed targeting key. Emitted for full-tier rows
* when {@code observeFullEvaluationData} is off. The suffix is the lower-case hex SHA-256 of the
* UTF-8 targeting key (see {@link ULeb128Encoder#hashTargetingKey}). This is a cross-SDK wire
* contract - keep it in sync with the other server SDKs and the UFC/EVP spec.
*/
private static final String HASHED_TARGETING_KEY_PREFIX = "sha256_";

private static final JsonAdapter<FlagEvaluationEvent> EVENT_JSON_ADAPTER;
private static final JsonAdapter<Map<String, String>> CONTEXT_JSON_ADAPTER;

Expand Down Expand Up @@ -194,7 +203,9 @@ static class FlagEvaluationEvent {
static FlagEvaluationEvent fromBucket(
final FlagEvaluationAggregator.EvalBucket bucket,
final boolean isFullTier,
final boolean observeFullEvaluationData,
final long flushTimeMs) {
final boolean includeRawContext = isFullTier && observeFullEvaluationData;
return new FlagEvaluationEvent(
flushTimeMs,
bucket.flagKey,
Expand All @@ -203,10 +214,23 @@ static FlagEvaluationEvent fromBucket(
bucket.count,
bucket.variant,
bucket.allocationKey,
isFullTier ? bucket.targetingKey : null,
resolveTargetingKey(bucket.targetingKey, isFullTier, observeFullEvaluationData),
bucket.runtimeDefaultUsed,
bucket.errorMessage,
isFullTier ? bucket.prunedAttrs : null);
includeRawContext ? bucket.prunedAttrs : null);
}

private static String resolveTargetingKey(
final String rawTargetingKey,
final boolean isFullTier,
final boolean observeFullEvaluationData) {
if (!isFullTier || rawTargetingKey == null) {
return null;
}
if (observeFullEvaluationData) {
return rawTargetingKey;
}
return HASHED_TARGETING_KEY_PREFIX + ULeb128Encoder.hashTargetingKey(rawTargetingKey);
}

FlagEvaluationEvent withoutTargetingKeyAndContext() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -471,15 +471,20 @@ void flush() {

private List<FlagEvaluationPayloads.FlagEvaluationEvent> buildEventList() {
final long flushTimeMs = System.currentTimeMillis();
// Consent is read per bucket from the value captured at aggregation time, not from the
// gateway here: CURRENT_CONFIG may have been overwritten by a later RC update since these
// evaluations happened, and reading it at flush would apply the wrong environment's consent.
final List<FlagEvaluationPayloads.FlagEvaluationEvent> events =
new ArrayList<>(aggregator.bucketCount());
for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.fullBuckets()) {
events.add(
FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, true, flushTimeMs));
FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(
bucket, true, bucket.observeFullEvaluationData, flushTimeMs));
}
for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.degradedBuckets()) {
events.add(
FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, false, flushTimeMs));
FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(
bucket, false, bucket.observeFullEvaluationData, flushTimeMs));
}
return events;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import datadog.trace.api.featureflag.FeatureFlaggingGateway;
import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent;
import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
Expand Down Expand Up @@ -212,7 +214,7 @@ void flagEvalEventDoesNotCarryReason() {
void evalBucketTracksBoundsDefaultStateAndNullContextFieldCount() {
final FlagEvaluationAggregator.EvalBucket bucket =
new FlagEvaluationAggregator.EvalBucket(
"bucket-flag", "on", "alloc1", "user-1", null, 1000L, false, null);
"bucket-flag", "on", "alloc1", "user-1", null, 1000L, false, null, false);

assertEquals(0, bucket.prunedContextFieldCount());

Expand Down Expand Up @@ -266,6 +268,52 @@ void degradedKeyEqualityUsesEveryDimension() {
assertNotEquals(base, degradedKey("flag", "on", "alloc", false, "other"));
}

@Test
void observeFullEvaluationDataFoldsToFalseWhenAnyMergedEvaluationLacksConsent() {
final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator();
try {
FeatureFlaggingGateway.dispatch(observeConfig(true));
aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, emptyMap()));
// A later RC update turns consent off; the second evaluation folds into the same bucket.
FeatureFlaggingGateway.dispatch(observeConfig(false));
aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, emptyMap()));

final FlagEvaluationAggregator.EvalBucket bucket =
aggregator.snapshot().fullTier.values().iterator().next();
assertEquals(2, bucket.count);
// Conservative fold: one no-consent evaluation sinks the whole bucket to hashed/omitted.
assertFalse(bucket.observeFullEvaluationData);
} finally {
FeatureFlaggingGateway.dispatch((ServerConfiguration) null);
}
}

@Test
void observeFullEvaluationDataStaysTrueWhenEveryMergedEvaluationConsents() {
final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator();
try {
FeatureFlaggingGateway.dispatch(observeConfig(true));
aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, emptyMap()));
aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, emptyMap()));

final FlagEvaluationAggregator.EvalBucket bucket =
aggregator.snapshot().fullTier.values().iterator().next();
assertEquals(2, bucket.count);
assertTrue(bucket.observeFullEvaluationData);
} finally {
FeatureFlaggingGateway.dispatch((ServerConfiguration) null);
}
}

private static ServerConfiguration observeConfig(final boolean observeFullEvaluationData) {
return new ServerConfiguration(
"2024-04-17T19:40:53.716Z",
"SERVER",
observeFullEvaluationData,
null,
java.util.Collections.emptyMap());
}

private static FlagEvalEvent event(
final String flagKey,
final String variant,
Expand Down
Loading
Loading