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 @@ -3,4 +3,12 @@
public class FeatureFlaggingConfig {

public static final String FLAGGING_PROVIDER_ENABLED = "experimental.flagging.provider.enabled";

/**
* Killswitch for the EVP {@code flagevaluation} emission path. Default: enabled. Disabling it
* turns off EVP flag-evaluation counts while leaving the OTel {@code feature_flag.evaluations}
* metric path untouched. Maps to {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED}.
*/
public static final String FLAGGING_EVALUATION_COUNTS_ENABLED =
"flagging.evaluation.counts.enabled";
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ public enum AgentThread {

LLMOBS_EVALS_PROCESSOR("dd-llmobs-evals-processor"),

FEATURE_FLAG_EXPOSURE_PROCESSOR("dd-ffe-exposure-processor");
FEATURE_FLAG_EXPOSURE_PROCESSOR("dd-ffe-exposure-processor"),

FEATURE_FLAG_EVALUATION_PROCESSOR("dd-ffe-evaluation-processor");

public final String threadName;

Expand Down
8 changes: 8 additions & 0 deletions metadata/supported-configurations.json
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,14 @@
"aliases": []
}
],
"DD_FLAGGING_EVALUATION_COUNTS_ENABLED": [
{
"version": "A",
"type": "boolean",
"default": "true",
"aliases": []
}
],
"DD_FORCE_CLEAR_TEXT_HTTP_FOR_INTAKE_CLIENT": [
{
"version": "A",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import datadog.communication.ddagent.SharedCommunicationObjects;
import datadog.trace.api.Config;
import datadog.trace.api.config.FeatureFlaggingConfig;
import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -11,6 +13,7 @@ public class FeatureFlaggingSystem {

private static volatile RemoteConfigService CONFIG_SERVICE;
private static volatile ExposureWriter EXPOSURE_WRITER;
private static volatile FlagEvaluationWriter FLAG_EVAL_WRITER;

private FeatureFlaggingSystem() {}

Expand All @@ -27,10 +30,31 @@ public static void start(final SharedCommunicationObjects sco) {
EXPOSURE_WRITER = new ExposureWriterImpl(sco, config);
EXPOSURE_WRITER.init();

// EVP flagevaluation writer — gated by the killswitch
// DD_FLAGGING_EVALUATION_COUNTS_ENABLED (default: on), read through the tracer config system.
final boolean evalCountsEnabled =
config
.configProvider()
.getBoolean(FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, true);
if (evalCountsEnabled) {
final FlagEvaluationWriterImpl evalWriter = new FlagEvaluationWriterImpl(sco, config);
evalWriter.start(); // registers with FeatureFlaggingGateway
FLAG_EVAL_WRITER = evalWriter;
LOGGER.debug("Flag evaluation EVP writer started");
} else {
LOGGER.debug(
"Flag evaluation EVP writer disabled ({}=false)",
FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED);
}

LOGGER.debug("Feature Flagging system started");
}

public static void stop() {
if (FLAG_EVAL_WRITER != null) {
FLAG_EVAL_WRITER.close(); // also deregisters from gateway
FLAG_EVAL_WRITER = null;
}
if (EXPOSURE_WRITER != null) {
EXPOSURE_WRITER.close();
EXPOSURE_WRITER = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,11 +387,15 @@ private static <T> ProviderEvaluation<T> resolveVariant(
+ e.getMessage());
}

// Stamp eval-time at the resolution point so first/last_evaluation reflect evaluation time,
// not hook-fire time. Passed to the hook via provider metadata "dd.eval.timestamp_ms".
final long evalTimestampMs = System.currentTimeMillis();
final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder =
ImmutableMetadata.builder()
.addString("flagKey", flag.key)
.addString("variationType", flag.variationType.name())
.addString("allocationKey", allocation.key);
.addString("allocationKey", allocation.key)
.addLong("dd.eval.timestamp_ms", evalTimestampMs);
final ProviderEvaluation<T> result =
ProviderEvaluation.<T>builder()
.value(mappedValue)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package datadog.trace.api.openfeature;

import datadog.trace.api.featureflag.FeatureFlaggingGateway;
import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent;
import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter;
import dev.openfeature.sdk.FlagEvaluationDetails;
import dev.openfeature.sdk.Hook;
import dev.openfeature.sdk.HookContext;
import dev.openfeature.sdk.ImmutableMetadata;
import java.util.Collections;
import java.util.Map;

/**
* OpenFeature {@code Hook<T>} that captures flag evaluation events for EVP {@code flagevaluation}
* emission.
*
* <p>Contract: {@code finallyAfter} does ONLY cheap scalar extraction + a non-blocking offer to the
* writer's bounded queue. No inline aggregation on the hook thread.
*
* <p>This hook is registered alongside the existing OTel {@link FlagEvalHook} — it does NOT replace
* it (the existing OTel metrics hook is left unchanged).
*
* <p>The writer is resolved lazily from {@link FeatureFlaggingGateway#getFlagEvalWriter()} on each
* call, so the hook is always safe to register — if the writer is absent (killswitch off or not yet
* started) it is a no-op.
*/
class FlagEvalEVPHook<T> implements Hook<T> {

/**
* Singleton instance: always registered when the provider is created; harmless when writer=null
* (killswitch off or not yet started).
*/
static final FlagEvalEVPHook<Object> INSTANCE = new FlagEvalEVPHook<>(null);

/**
* Optional injected writer (test-only). When non-null, bypasses the gateway lookup. Production
* instances use {@code null} (resolved via gateway).
*/
private final FlagEvaluationWriter injectedWriter;

/** Production constructor — resolves writer from gateway. */
FlagEvalEVPHook() {
this.injectedWriter = null;
}

/** Test-only constructor — injects a writer directly, bypassing the gateway. */
FlagEvalEVPHook(final FlagEvaluationWriter writer) {
this.injectedWriter = writer;
}

/**
* Cheap capture + non-blocking enqueue only. Runs at the {@code finally} stage so it covers
* success, error, and default-value paths.
*/
@Override
public void finallyAfter(
final HookContext<T> ctx,
final FlagEvaluationDetails<T> details,
final Map<String, Object> hints) {
// Resolve writer: prefer injected (test), then gateway
final FlagEvaluationWriter w =
injectedWriter != null ? injectedWriter : FeatureFlaggingGateway.getFlagEvalWriter();
if (w == null || details == null) {
return;
}
try {
// Cheap scalar extraction — no JSON, no map lookups beyond metadata.asMap()
final String flagKey = details.getFlagKey();
final ImmutableMetadata metadata = details.getFlagMetadata();

// allocationKey: "allocationKey" (camelCase) — consistent with FlagEvalHook.java
final String allocationKey = metadata != null ? metadata.getString("allocationKey") : null;

// eval-time: from flag metadata "dd.eval.timestamp_ms" (Long), fallback to hook-fire time.
// ImmutableMetadata.getLong available since sdk 1.4+.
final Long evalTimeObj = metadata != null ? metadata.getLong("dd.eval.timestamp_ms") : null;
final long evalTimeMs = evalTimeObj != null ? evalTimeObj : System.currentTimeMillis();

// variant: the OpenFeature variant key (same source as the OTel FlagEvalHook), NOT the
// evaluated value. A null variant means no variant was selected (runtime default).
final String variant = details.getVariant();

// error message: prefer the human-readable message; fall back to the error code name when
// the message is empty (some providers populate only the code). null on success.
String errorMessage = details.getErrorMessage();
if ((errorMessage == null || errorMessage.isEmpty()) && details.getErrorCode() != null) {
errorMessage = details.getErrorCode().name();
}
if (errorMessage != null && errorMessage.isEmpty()) {
errorMessage = null;
}

// targetingKey from evaluation context
final String targetingKey =
ctx != null && ctx.getCtx() != null ? ctx.getCtx().getTargetingKey() : null;

// attrs: flatten EvaluationContext attributes for the full-tier canonical key
final Map<String, Object> attrs = extractAttrs(ctx);

w.enqueue(
new FlagEvalEvent(
flagKey, variant, allocationKey, targetingKey, errorMessage, evalTimeMs, attrs));
} catch (Exception e) {
// Never let EVP recording break flag evaluation
}
}

/** Extracts converted, flattened attributes from the evaluation context. */
private Map<String, Object> extractAttrs(final HookContext<T> ctx) {
if (ctx == null || ctx.getCtx() == null) {
return Collections.emptyMap();
}
return DDEvaluator.flattenContext(ctx.getCtx());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import dev.openfeature.sdk.exceptions.OpenFeatureError;
import dev.openfeature.sdk.exceptions.ProviderNotReadyError;
import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -168,10 +169,14 @@ private Evaluator buildEvaluator() throws Exception {

@Override
public List<Hook> getProviderHooks() {
if (flagEvalHook == null) {
return Collections.emptyList();
final List<Hook> hooks = new ArrayList<>(2);
if (flagEvalHook != null) {
hooks.add(flagEvalHook);
}
return Collections.singletonList(flagEvalHook);
// EVP flagevaluation hook: always registered; no-op when writer is absent (killswitch off).
// Writer is resolved lazily from FeatureFlaggingGateway.getFlagEvalWriter() on each call.
hooks.add(FlagEvalEVPHook.INSTANCE);
return Collections.unmodifiableList(hooks);
}

@Override
Expand Down
Loading