diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 45e12e44c85..b9dc242c780 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -1604,6 +1604,14 @@ "aliases": [] } ], + "DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "false", + "aliases": [] + } + ], "DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED": [ { "version": "A", diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index 7afc0cd0359..5aa7153906c 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -59,6 +59,57 @@ boolean enabled = client.getBooleanValue("my-feature", false, new MutableContext("user-123")); ``` +## Per-subject evaluation telemetry opt-out + +Applications can keep flag evaluation enabled while excluding selected subjects from evaluation +telemetry. Configure the provider once, then pass the decision with each evaluation: + +```java +import datadog.trace.api.openfeature.Provider; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Value; +import java.util.Collections; + +OpenFeatureAPI api = OpenFeatureAPI.getInstance(); +api.setProviderAndWait(new Provider( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(true))); +Client client = api.getClient(); + +// In a request handler, the application owns its consent and eligibility rules. +boolean optedOut = user.hasDeclinedEvaluationTelemetry(); +boolean enabled = client.getBooleanValue("my-feature", false, + new ImmutableContext(user.getId(), Collections.singletonMap( + "dd_evaluation_telemetry_opt_out", new Value(optedOut)))); +``` + +Use the per-evaluation context for concurrent requests. Do not replace the global context for each +subject. The key is exactly `dd_evaluation_telemetry_opt_out`, as in other Datadog SDKs. + +Alternatively, enable support through deployment configuration and use `new Provider()`: + +```shell +DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED=true +``` + +The equivalent JVM property is `-Ddd.feature.flags.evaluation.telemetry.per.subject.opt.out.enabled=true`. +The provider reads the normal Datadog configuration sources at construction. The default is `false`. +An explicit provider option overrides these sources, including an explicit `false`. +This setting only enables support for the subject attribute. It does not suppress telemetry for all subjects. + +Only boolean `true` suppresses telemetry. Missing, false, null, and other types preserve telemetry. +The provider suppresses telemetry only when the option is enabled before provider construction. + +The attribute remains available for targeting and exposure reporting with either activation setting. +Existing privacy and reporting settings apply. Targeting rules can use the attribute to select +assignments. Normal exposure deduplication applies when its value changes. + +Opt-out suppresses new evaluation EVP events and counts, OpenTelemetry evaluation metrics, and +automatic flag-related span enrichment. It does not change flag values, evaluation details, +application defaults, experiment assignment, or exposure logging. Application hooks and unrelated +tracing remain active. Previously recorded or queued data is not removed. + ## Evaluation metrics When `DD_METRICS_OTEL_ENABLED=true` and the OpenTelemetry API is on the classpath, the provider diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index 621b355b2fc..0c5a4db5dd4 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { // branch cannot be driven. testImplementation(project(":products:feature-flagging:feature-flagging-config")) testImplementation(project(":utils:config-utils")) + testImplementation(project(":components:environment")) testImplementation("io.opentelemetry:opentelemetry-api:1.47.0") testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutHook.java new file mode 100644 index 00000000000..f64d9966a9f --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutHook.java @@ -0,0 +1,55 @@ +package datadog.trace.api.openfeature; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.Value; +import java.util.Map; +import java.util.Optional; + +/** Gates a Datadog finally-only evaluation telemetry hook, not exposure or application hooks. */ +final class EvaluationTelemetryOptOutHook implements Hook { + private static final String PARAMETER = "dd_evaluation_telemetry_opt_out"; + private static final String DECISION = "datadog.evaluationTelemetryOptOut"; + private final Hook delegate; + + EvaluationTelemetryOptOutHook(final Hook delegate) { + this.delegate = delegate; + } + + @Override + public Optional before( + final HookContext context, final Map hints) { + // Provider before hooks see the merged context, including application before hooks. + // HookData belongs to this evaluation, including nested and concurrent calls. + context.getHookData().set(DECISION, isOptedOut(context.getCtx())); + return Optional.empty(); + } + + @Override + public void error( + final HookContext context, final Exception error, final Map hints) { + // A failing application before hook can prevent our before hook from running. + // Preserve its decision before application error hooks can change the context. + if (context.getHookData().get(DECISION) == null) { + context.getHookData().set(DECISION, isOptedOut(context.getCtx())); + } + } + + @Override + public void finallyAfter( + final HookContext context, + final FlagEvaluationDetails details, + final Map hints) { + if (Boolean.TRUE.equals(context.getHookData().get(DECISION)) || isOptedOut(context.getCtx())) { + return; + } + delegate.finallyAfter(context, details, hints); + } + + private static boolean isOptedOut(final EvaluationContext context) { + final Value value = context == null ? null : context.getValue(PARAMETER); + return value != null && value.isBoolean() && Boolean.TRUE.equals(value.asBoolean()); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java index 436f4facb4a..b5dc224392c 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -2,6 +2,8 @@ import static java.util.concurrent.TimeUnit.SECONDS; +import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; +import datadog.trace.bootstrap.config.provider.ConfigProvider; import de.thetaphi.forbiddenapis.SuppressForbidden; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; @@ -104,6 +106,12 @@ public Provider(final Options options) { if (spanEnrichmentHook != null) { hooks.add(spanEnrichmentHook); } + if (options.isEvaluationTelemetryOptOutParameterEnabled()) { + for (int i = 0; i < hooks.size(); i++) { + hooks.set(i, new EvaluationTelemetryOptOutHook(hooks.get(i))); + } + } + // Exposure logging is independent of evaluation telemetry opt-out. try { hooks.add(buildExposureLoggingHook()); } catch (LinkageError e) { @@ -312,8 +320,47 @@ private enum InitializationState { public static class Options { - private long timeout; - private TimeUnit unit; + private long timeout = 30; + private TimeUnit unit = SECONDS; + private Boolean evaluationTelemetryOptOutParameterEnabled; + + /** + * Enables per-evaluation telemetry opt-out through the {@code dd_evaluation_telemetry_opt_out} + * context attribute. Overrides {@code + * DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED}, including when false. + * Without an explicit option, the provider reads configuration at construction. Disabled by + * default. + * + *

Only boolean {@code true} suppresses new Datadog evaluation events, evaluation metrics, + * and automatic flag span enrichment. Missing or other values preserve telemetry. This does not + * change flag results, exposure logging, application hooks, or previously recorded data. + * + *

The attribute remains available for targeting and exposure reporting. Existing privacy and + * reporting settings apply. + * + * @param enabled whether to honor the attribute; configure before constructing the provider + * @return these options + */ + public Options enableEvaluationTelemetryOptOutParameter(final boolean enabled) { + this.evaluationTelemetryOptOutParameterEnabled = enabled; + return this; + } + + private boolean isEvaluationTelemetryOptOutParameterEnabled() { + if (evaluationTelemetryOptOutParameterEnabled != null) { + return evaluationTelemetryOptOutParameterEnabled; + } + try { + return ConfigProvider.getInstance() + .getBoolean( + FeatureFlaggingConfig + .FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED, + false); + } catch (final LinkageError | Exception e) { + // Keep provider construction compatible when agent configuration classes are unavailable. + return false; + } + } public Options initTimeout(final long timeout, final TimeUnit unit) { this.timeout = timeout; diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutTest.java new file mode 100644 index 00000000000..34cb51bbbba --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/EvaluationTelemetryOptOutTest.java @@ -0,0 +1,683 @@ +package datadog.trace.api.openfeature; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import datadog.environment.EnvironmentVariables; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.SpanEnrichmentEvent; +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; +import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; +import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; +import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; +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 datadog.trace.api.featureflag.ufc.v1.ValueType; +import datadog.trace.api.featureflag.ufc.v1.Variant; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.multiprovider.MultiProvider; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongCounter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Exercises real OpenFeature resolution and telemetry hooks, with capture at each output boundary. + */ +class EvaluationTelemetryOptOutTest { + private static final String PARAMETER = "dd_evaluation_telemetry_opt_out"; + private static final String ENVIRONMENT_VARIABLE = + "DD_FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED"; + private static final String SYSTEM_PROPERTY = + "dd.feature.flags.evaluation.telemetry.per.subject.opt.out.enabled"; + private EnvironmentVariables.EnvironmentVariablesProvider previousEnvironment; + private String previousProperty; + private String configuredEnvironmentValue; + + private final List events = new CopyOnWriteArrayList<>(); + private final List spans = new CopyOnWriteArrayList<>(); + private final List exposures = new CopyOnWriteArrayList<>(); + private final List customerEvaluations = new CopyOnWriteArrayList<>(); + private final AtomicInteger metrics = new AtomicInteger(); + private final FeatureFlaggingGateway.SpanEnrichmentListener spanListener = spans::add; + private final FeatureFlaggingGateway.ExposureListener exposureListener = exposures::add; + private final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + private FlagEvaluationWriter previousWriter; + private boolean previousEnqueueEnabled; + private Client client; + + @BeforeEach + void setUp() { + previousEnvironment = EnvironmentVariables.provider; + previousProperty = System.getProperty(SYSTEM_PROPERTY); + System.clearProperty(SYSTEM_PROPERTY); + EnvironmentVariables.provider = + new EnvironmentVariables.EnvironmentVariablesProvider() { + @Override + public String get(final String name) { + return ENVIRONMENT_VARIABLE.equals(name) + ? configuredEnvironmentValue + : previousEnvironment.get(name); + } + }; + api.setEvaluationContext(new MutableContext()); + GlobalOpenTelemetry.resetForTest(); + final OpenTelemetry otel = mock(OpenTelemetry.class, RETURNS_DEEP_STUBS); + final LongCounter counter = mock(LongCounter.class); + when(otel.getMeterProvider() + .meterBuilder("ddtrace.openfeature") + .build() + .counterBuilder("feature_flag.evaluations") + .setUnit("{evaluation}") + .setDescription("Number of feature flag evaluations") + .build()) + .thenReturn(counter); + doAnswer(invocation -> metrics.addAndGet(1)).when(counter).add(eq(1L), any(Attributes.class)); + GlobalOpenTelemetry.set(otel); + + previousWriter = FeatureFlaggingGateway.getFlagEvalWriter(); + previousEnqueueEnabled = FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled(); + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + doAnswer(invocation -> events.add(invocation.getArgument(0))).when(writer).enqueue(any()); + FeatureFlaggingGateway.setFlagEvalWriter(writer); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + FeatureFlaggingGateway.addSpanEnrichmentListener(spanListener); + FeatureFlaggingGateway.addExposureListener(exposureListener); + ExposureDeduplicationCache.INSTANCE.clear(); + api.addHooks(new ExposureHook(customerEvaluations::add)); + } + + @AfterEach + void tearDown() { + EnvironmentVariables.provider = previousEnvironment; + if (previousProperty == null) { + System.clearProperty(SYSTEM_PROPERTY); + } else { + System.setProperty(SYSTEM_PROPERTY, previousProperty); + } + api.clearHooks(); + api.setEvaluationContext(new MutableContext()); + api.shutdown(); + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + FeatureFlaggingGateway.removeSpanEnrichmentListener(spanListener); + FeatureFlaggingGateway.removeExposureListener(exposureListener); + FeatureFlaggingGateway.setFlagEvalWriter(previousWriter); + FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(previousEnqueueEnabled); + ExposureDeduplicationCache.INSTANCE.clear(); + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void environmentEnablesPerSubjectOptOut() throws Exception { + configuredEnvironmentValue = "true"; + start(new Provider.Options()); + final FlagEvaluationDetails details = evaluate("subject-1", new Value(true)); + assertEquals(1, details.getValue()); + assertEquals("on", details.getVariant()); + assertTelemetryCounts(0); + assertEquals(1, exposures.size()); + evaluate("subject-2", new Value(false)); + evaluate("subject-3", null); + assertTelemetryCounts(2); + assertEquals(3, exposures.size()); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"false", "invalid", ""}) + void environmentDefaultsToDisabled(final String value) throws Exception { + configuredEnvironmentValue = value; + start(new Provider.Options()); + evaluate("subject-1", new Value(true)); + assertTelemetryCounts(1); + assertEquals(1, exposures.size()); + } + + @Test + void explicitFalseOverridesEnabledEnvironment() throws Exception { + configuredEnvironmentValue = "true"; + start(new Provider.Options().enableEvaluationTelemetryOptOutParameter(false)); + evaluate("subject-1", new Value(true)); + assertTelemetryCounts(1); + assertEquals(1, exposures.size()); + } + + @Test + void explicitTrueOverridesDisabledEnvironment() throws Exception { + configuredEnvironmentValue = "false"; + start(new Provider.Options().enableEvaluationTelemetryOptOutParameter(true)); + evaluate("subject-1", new Value(true)); + assertTelemetryCounts(0); + assertEquals(1, exposures.size()); + } + + @Test + void systemPropertyOverridesEnvironment() throws Exception { + configuredEnvironmentValue = "true"; + System.setProperty(SYSTEM_PROPERTY, "false"); + start(new Provider.Options()); + evaluate("subject-1", new Value(true)); + assertTelemetryCounts(1); + } + + @Test + void environmentIsCapturedAtProviderConstruction() throws Exception { + configuredEnvironmentValue = "true"; + final Provider.Options options = new Provider.Options(); + start(options); + configuredEnvironmentValue = "false"; + evaluate("subject-1", new Value(true)); + assertTelemetryCounts(0); + api.shutdown(); + start(options); + evaluate("subject-2", new Value(true)); + assertTelemetryCounts(1); + } + + @Test + void optedOutSubjectKeepsAssignmentAndExposureButEmitsNoEvaluationTelemetry() throws Exception { + start(); + + final FlagEvaluationDetails details = evaluate("subject-1", new Value(true)); + + assertNull(details.getErrorCode()); + assertEquals(1, details.getValue()); + assertEquals("on", details.getVariant()); + assertEquals(1, exposures.size()); + assertEquals(1, customerEvaluations.size()); + assertTelemetryCounts(0); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void telemetryAttributeRemainsAvailableForTargetingAndExposure(final boolean enabled) + throws Exception { + start(new Provider.Options().enableEvaluationTelemetryOptOutParameter(enabled)); + + for (final boolean observeFullEvaluationData : new boolean[] {false, true}) { + FeatureFlaggingGateway.dispatch(targetingConfiguration(observeFullEvaluationData)); + final String subject = "subject-" + observeFullEvaluationData; + final int previousEvents = events.size(); + final int previousExposures = exposures.size(); + + final FlagEvaluationDetails allowed = evaluate(subject, new Value(false)); + final FlagEvaluationDetails optedOut = evaluate(subject, new Value(true)); + final FlagEvaluationDetails repeated = evaluate(subject, new Value(true)); + final FlagEvaluationDetails stringValue = + evaluate(subject + "-string", new Value("true")); + + assertNull(allowed.getErrorCode()); + assertEquals(1, allowed.getValue()); + assertEquals("on", allowed.getVariant()); + for (final FlagEvaluationDetails details : asList(optedOut, repeated, stringValue)) { + assertNull(details.getErrorCode()); + assertEquals(2, details.getValue(), "the ordinary targeting rule sees the attribute"); + assertEquals("targeted", details.getVariant()); + } + assertEquals( + previousExposures + 3, exposures.size(), "normal exposure deduplication applies"); + assertEquals(false, exposures.get(previousExposures).subject.attributes.get(PARAMETER)); + assertEquals(true, exposures.get(previousExposures + 1).subject.attributes.get(PARAMETER)); + assertEquals("true", exposures.get(previousExposures + 2).subject.attributes.get(PARAMETER)); + assertTelemetryCounts(previousEvents + (enabled ? 2 : 4)); + + if (observeFullEvaluationData) { + assertEquals(false, events.get(previousEvents).attrs.get(PARAMETER)); + assertEquals("true", events.get(events.size() - 1).attrs.get(PARAMETER)); + if (!enabled) { + assertEquals(true, events.get(previousEvents + 1).attrs.get(PARAMETER)); + } + } else { + for (final FlagEvalEvent event : events.subList(previousEvents, events.size())) { + assertTrue(event.attrs.isEmpty(), "normal evaluation privacy rules still apply"); + } + } + } + } + + @Test + void allowedSubjectEmitsEachSignal() throws Exception { + start(); + + evaluate("subject-1", new Value(false)); + + assertTelemetryCounts(1); + assertEquals(1, exposures.size()); + assertEquals(1, customerEvaluations.size()); + } + + @ParameterizedTest + @MethodSource("nonOptOutValues") + void onlyBooleanTrueSuppressesTelemetry(final Value value) throws Exception { + start(); + + evaluate("subject-1", value); + + assertTelemetryCounts(1); + assertEquals(1, exposures.size()); + } + + static Stream nonOptOutValues() { + return Stream.of( + Arguments.of((Value) null), + Arguments.of(new Value()), + Arguments.of(new Value(false)), + Arguments.of(new Value("true")), + Arguments.of(new Value(1)), + Arguments.of(new Value(singletonList(new Value(true))))); + } + + @Test + void theParameterHasNoEffectUnlessEnabled() throws Exception { + start(new Provider.Options()); + + evaluate("subject-1", new Value(true)); + + assertTelemetryCounts(1); + assertEquals(1, exposures.size()); + } + + @Test + void theParameterIsCaseSensitiveAndDoesNotAcceptTheOldName() throws Exception { + start(); + + client.getIntegerDetails( + "flag", + 0, + new MutableContext("subject-1") + .add("dd_telemetry_opt_out", true) + .add("ddEvaluationTelemetryOptOut", true)); + + assertTelemetryCounts(1); + } + + @Test + void providerOptionsAreCapturedAtConstruction() throws Exception { + final Provider.Options options = + new Provider.Options().enableEvaluationTelemetryOptOutParameter(true); + start(options); + options.enableEvaluationTelemetryOptOutParameter(false); + + evaluate("subject-1", new Value(true)); + + assertTelemetryCounts(0); + } + + @Test + void eachEvaluationUsesItsOwnDecisionWithoutChangingExistingEvents() throws Exception { + start(); + + evaluate("same-subject", new Value(false)); + evaluate("same-subject", new Value(true)); + assertTelemetryCounts(1); + evaluate("same-subject", new Value(false)); + + assertTelemetryCounts(2); + assertEquals(1, exposures.size(), "exposure deduplication remains independent"); + assertEquals(3, customerEvaluations.size()); + } + + @Test + void missingAndWrongTypeFlagsKeepDefaultsWithoutEmittingTelemetry() throws Exception { + start(); + final EvaluationContext context = context("subject-1", new Value(true)); + + final FlagEvaluationDetails missing = client.getIntegerDetails("missing", 42, context); + final FlagEvaluationDetails wrongType = + client.getStringDetails("flag", "default", context); + + assertEquals(ErrorCode.FLAG_NOT_FOUND, missing.getErrorCode()); + assertEquals(42, missing.getValue()); + assertEquals(ErrorCode.TYPE_MISMATCH, wrongType.getErrorCode()); + assertEquals("default", wrongType.getValue()); + assertTelemetryCounts(0); + assertEquals(0, exposures.size()); + assertEquals(2, customerEvaluations.size()); + + client.getIntegerDetails("missing", 42, context("subject-2", new Value(false))); + assertTelemetryCounts(1); + } + + @Test + void contextMergingHonorsThePerEvaluationOverride() throws Exception { + start(); + api.setEvaluationContext(new MutableContext().add(PARAMETER, true)); + + client.getIntegerDetails("flag", 0, new ImmutableContext("subject-1")); + evaluate("subject-2", new Value(false)); + + assertTelemetryCounts(1); + assertEquals("subject-2", events.get(0).targetingKey); + assertEquals(2, exposures.size()); + } + + @Test + void applicationBeforeHooksCanSupplyTheOptOutDecision() throws Exception { + start(); + api.addHooks( + new Hook() { + @Override + public Optional before( + final HookContext context, final Map hints) { + return Optional.of(new ImmutableContext(singletonMap(PARAMETER, new Value(true)))); + } + }); + + evaluate("subject-1", new Value(false)); + + assertTelemetryCounts(0); + assertEquals(1, exposures.size()); + } + + @Test + void applicationAfterHooksCannotUndoTheEvaluationDecision() throws Exception { + start(); + final MutableContext invocationContext = new MutableContext("subject-1").add(PARAMETER, true); + api.addHooks( + new Hook() { + @Override + public void after( + final HookContext context, + final FlagEvaluationDetails details, + final Map hints) { + invocationContext.add(PARAMETER, false); + } + }); + + final FlagEvaluationDetails details = + client.getIntegerDetails("flag", 0, invocationContext); + + assertNull(details.getErrorCode()); + assertTelemetryCounts(0); + assertEquals(1, exposures.size()); + } + + @Test + void optingOutInAnAfterHookStopsTelemetryBeforeEmission() throws Exception { + start(); + final MutableContext invocationContext = new MutableContext("subject-1").add(PARAMETER, false); + api.addHooks( + new Hook() { + @Override + public void after( + final HookContext context, + final FlagEvaluationDetails details, + final Map hints) { + invocationContext.add(PARAMETER, true); + } + }); + + final FlagEvaluationDetails details = + client.getIntegerDetails("flag", 0, invocationContext); + + assertNull(details.getErrorCode()); + assertTelemetryCounts(0); + assertEquals(1, exposures.size()); + } + + @Test + void aFailingBeforeHookDoesNotLeakErrorTelemetry() throws Exception { + start(); + final MutableContext invocationContext = new MutableContext("subject-1").add(PARAMETER, true); + api.addHooks( + new Hook() { + @Override + public Optional before( + final HookContext context, final Map hints) { + throw new IllegalStateException("application hook failed"); + } + + @Override + public void error( + final HookContext context, + final Exception error, + final Map hints) { + invocationContext.add(PARAMETER, false); + } + }); + + final FlagEvaluationDetails details = + client.getIntegerDetails("flag", 0, invocationContext); + + assertEquals(ErrorCode.GENERAL, details.getErrorCode()); + assertEquals(0, details.getValue()); + assertTelemetryCounts(0); + assertEquals(0, exposures.size()); + assertEquals(1, customerEvaluations.size()); + } + + @Test + void nestedEvaluationsKeepIndependentDecisions() throws Exception { + start(); + api.addHooks( + new Hook() { + @Override + public void after( + final HookContext context, + final FlagEvaluationDetails details, + final Map hints) { + if ("outer".equals(context.getCtx().getTargetingKey())) { + assertEquals(1, evaluate("inner", new Value(false)).getValue()); + } + } + }); + + assertEquals(1, evaluate("outer", new Value(true)).getValue()); + + assertTelemetryCounts(1); + assertEquals("inner", events.get(0).targetingKey); + assertEquals(2, exposures.size()); + assertEquals(2, customerEvaluations.size()); + } + + @Test + void concurrentSubjectsCannotChangeEachOthersDecision() throws Exception { + start(); + final CountDownLatch optedOutEvaluated = new CountDownLatch(1); + final CountDownLatch allowOptedOutToFinish = new CountDownLatch(1); + api.addHooks( + new Hook() { + @Override + public void after( + final HookContext context, + final FlagEvaluationDetails details, + final Map hints) { + if ("opted-out".equals(context.getCtx().getTargetingKey())) { + optedOutEvaluated.countDown(); + try { + assertTrue(allowOptedOutToFinish.await(10, SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + } + }); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + final Future> pending = + executor.submit(() -> evaluate("opted-out", new Value(true))); + assertTrue(optedOutEvaluated.await(10, SECONDS)); + assertEquals(1, evaluate("allowed", new Value(false)).getValue()); + allowOptedOutToFinish.countDown(); + assertNull(pending.get(10, SECONDS).getErrorCode()); + + assertTelemetryCounts(1); + assertEquals("allowed", events.get(0).targetingKey); + assertEquals(2, exposures.size()); + } finally { + allowOptedOutToFinish.countDown(); + executor.shutdownNow(); + } + } + + @Test + void independentProvidersKeepTheirOwnActivationSetting() throws Exception { + start(); + api.setProviderAndWait( + "unrestricted", new Provider(new Provider.Options(), new DDEvaluator(() -> {}), true)); + + evaluate("opted-out", new Value(true)); + api.getClient("unrestricted") + .getIntegerDetails("flag", 0, context("unrestricted-subject", new Value(true))); + + assertTelemetryCounts(1); + assertEquals("unrestricted-subject", events.get(0).targetingKey); + assertEquals(2, exposures.size()); + } + + @Test + void optOutWorksInsideAMultiProvider() throws Exception { + start(); + api.setProviderAndWait( + new MultiProvider( + singletonList( + new Provider( + new Provider.Options().enableEvaluationTelemetryOptOutParameter(true), + new DDEvaluator(() -> {}), + true)))); + + assertEquals(1, evaluate("opted-out", new Value(true)).getValue()); + assertTelemetryCounts(0); + assertEquals(1, exposures.size()); + + assertEquals(1, evaluate("allowed", new Value(false)).getValue()); + assertTelemetryCounts(1); + assertEquals(2, exposures.size()); + } + + @Test + void missingConfigurationKeepsTheDefaultWithoutEmittingTelemetry() throws Exception { + start(); + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + + final FlagEvaluationDetails details = evaluate("subject-1", new Value(true)); + + assertEquals(ErrorCode.PROVIDER_NOT_READY, details.getErrorCode()); + assertEquals(0, details.getValue()); + assertTelemetryCounts(0); + assertEquals(0, exposures.size()); + assertEquals(1, customerEvaluations.size()); + } + + private void start() throws Exception { + start(new Provider.Options().enableEvaluationTelemetryOptOutParameter(true)); + } + + private static ServerConfiguration targetingConfiguration( + final boolean observeFullEvaluationData) { + final Rule rule = + new Rule( + singletonList( + new ConditionConfiguration( + ConditionOperator.ONE_OF, PARAMETER, singletonList(true)))); + final Allocation targeted = + new Allocation( + "targeted", + singletonList(rule), + null, + null, + singletonList(new Split(emptyList(), "targeted", emptyMap(), 7)), + true); + final Allocation fallback = + new Allocation( + "fallback", + null, + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 7)), + true); + final Map variants = new HashMap<>(); + variants.put("on", new Variant("on", 1)); + variants.put("targeted", new Variant("targeted", 2)); + final Flag flag = + new Flag("flag", true, ValueType.INTEGER, variants, asList(targeted, fallback)); + return new ServerConfiguration( + "", "", observeFullEvaluationData, null, singletonMap("flag", flag)); + } + + private void start(final Provider.Options options) throws Exception { + final Allocation allocation = + new Allocation( + "allocation", + null, + null, + null, + singletonList(new Split(emptyList(), "on", emptyMap(), 7)), + true); + final Flag flag = + new Flag( + "flag", + true, + ValueType.INTEGER, + singletonMap("on", new Variant("on", 1)), + singletonList(allocation)); + FeatureFlaggingGateway.dispatch( + new ServerConfiguration("", "", false, null, singletonMap("flag", flag))); + api.setProviderAndWait(new Provider(options, new DDEvaluator(() -> {}), true)); + client = api.getClient(); + } + + private FlagEvaluationDetails evaluate(final String subject, final Value optOut) { + return client.getIntegerDetails("flag", 0, context(subject, optOut)); + } + + private static EvaluationContext context(final String subject, final Value optOut) { + return new MutableContext( + subject, optOut == null ? emptyMap() : singletonMap(PARAMETER, optOut)); + } + + private void assertTelemetryCounts(final int expected) { + assertAll( + () -> assertEquals(expected, events.size(), "evaluation EVP enqueue count"), + () -> assertEquals(expected, metrics.get(), "OTel evaluation count"), + () -> assertEquals(expected, spans.size(), "automatic span enrichment count")); + } +} diff --git a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java index a8897c412d9..d95088b20c9 100644 --- a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java +++ b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java @@ -6,6 +6,8 @@ public class FeatureFlaggingConfig { public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; public static final String FEATURE_FLAGS_ENABLED = "feature.flags.enabled"; + public static final String FEATURE_FLAGS_EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED = + "feature.flags.evaluation.telemetry.per.subject.opt.out.enabled"; public static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = "experimental.flagging.provider.enabled";