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,13 @@
public class FeatureFlaggingConfig {

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

/**
* Opt-in gate for APM span enrichment with feature-flag evaluation metadata. The dot-form maps to
* {@code DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED} via the dot-to-underscore +
* {@code DD_} prefix normalization rule. This is DISTINCT from {@link #FLAGGING_PROVIDER_ENABLED}
* and is OFF by default — enabling the provider does not enable span enrichment.
*/
public static final String SPAN_ENRICHMENT_ENABLED =
"experimental.flagging.provider.span.enrichment.enabled";
}
8 changes: 8 additions & 0 deletions metadata/supported-configurations.json
Original file line number Diff line number Diff line change
Expand Up @@ -1497,6 +1497,14 @@
"aliases": []
}
],
"DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED": [
{
"version": "A",
"type": "boolean",
"default": "false",
"aliases": []
}
],
"DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [
{
"version": "B",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,18 @@ dependencies {

compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap"))
compileOnly(project(":utils:config-utils"))
// Span enrichment: TraceInterceptor / GlobalTracer / AgentTracer / AgentSpan for the
// write tier + active-root-span lookup. compileOnly because the agent runtime
// (feature-flagging-agent depends on :internal-api) provides these classes; the published
// dd-openfeature jar must not bundle the tracer.
compileOnly(project(":internal-api"))
compileOnly("io.opentelemetry:opentelemetry-api:1.47.0")
compileOnly("io.opentelemetry:opentelemetry-sdk-metrics:1.47.0")
compileOnly("io.opentelemetry:opentelemetry-exporter-otlp:1.47.0")

testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap"))
testImplementation(project(":internal-api"))
testImplementation(project(":utils:config-utils"))
testImplementation("io.opentelemetry:opentelemetry-api:1.47.0")
testImplementation("io.opentelemetry:opentelemetry-sdk-metrics:1.47.0")
testImplementation("io.opentelemetry:opentelemetry-exporter-otlp:1.47.0")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,14 @@ private static <T> ProviderEvaluation<T> resolveVariant(
.addString("flagKey", flag.key)
.addString("variationType", flag.variationType.name())
.addString("allocationKey", allocation.key);
// Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment.
// __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always
// present so the span-enrichment hook can decide whether to record the subject.
if (split.serialId != null) {
metadataBuilder.addString("__dd_split_serial_id", split.serialId.toString());
}
metadataBuilder.addString(
"__dd_do_log", String.valueOf(allocation.doLog != null && allocation.doLog));
final ProviderEvaluation<T> result =
ProviderEvaluation.<T>builder()
.value(mappedValue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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

import datadog.trace.api.GlobalTracer;
import datadog.trace.config.inversion.ConfigHelper;
import de.thetaphi.forbiddenapis.SuppressForbidden;
import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.EvaluationContext;
Expand All @@ -16,6 +18,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 All @@ -28,13 +31,28 @@ public class Provider extends EventProvider implements Metadata {
private static final Logger log = LoggerFactory.getLogger(Provider.class);
static final String METADATA = "datadog-openfeature-provider";
private static final String EVALUATOR_IMPL = "datadog.trace.api.openfeature.DDEvaluator";

/**
* Environment variable form of {@link
* datadog.trace.api.config.FeatureFlaggingConfig#SPAN_ENRICHMENT_ENABLED}. Distinct from the
* provider-enabled gate; OFF by default (experimental opt-in).
*/
static final String SPAN_ENRICHMENT_ENABLED_ENV =
"DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED";

private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS);
private volatile Evaluator evaluator;
private final Options options;
private final AtomicReference<InitializationState> initializationState =
new AtomicReference<>(InitializationState.NOT_STARTED);
private final FlagEvalMetrics flagEvalMetrics;
private final FlagEvalHook flagEvalHook;
// Span enrichment: null unless the gate is on, so the feature has no idle overhead when off.
private final SpanEnrichmentHook spanEnrichmentHook;
private final SpanEnrichmentStates spanEnrichmentStates;
// Precomputed hook list returned by getProviderHooks() on every evaluation. Immutable and built
// once so gate-off evaluation allocates nothing on this hot path.
private final List<Hook> providerHooks;

public Provider() {
this(DEFAULT_OPTIONS, null);
Expand All @@ -45,6 +63,44 @@ public Provider(final Options options) {
}

Provider(final Options options, final Evaluator evaluator) {
this(options, evaluator, null);
}

/**
* Registers a {@link SpanEnrichmentInterceptor} with the running tracer, returning {@code true}
* when it was added and {@code false} when the tracer rejected it (e.g. the interceptor is
* already registered, or the global tracer is the no-op placeholder). Injectable so tests can
* drive registration deterministically without mutating the global tracer (mirrors the {@code
* spanEnrichmentEnabledOverride} seam).
*/
interface TraceInterceptorRegistrar {
boolean register(SpanEnrichmentInterceptor interceptor);
}

/**
* @param spanEnrichmentEnabledOverride when non-null, forces the span-enrichment gate (test
* seam); when null, the gate is read from {@link #SPAN_ENRICHMENT_ENABLED_ENV}.
*/
Provider(
final Options options,
final Evaluator evaluator,
final Boolean spanEnrichmentEnabledOverride) {
this(
options,
evaluator,
spanEnrichmentEnabledOverride,
interceptor -> GlobalTracer.get().addTraceInterceptor(interceptor));
}

/**
* @param registrar registers the span-enrichment interceptor with the tracer; injectable for
* tests (see {@link TraceInterceptorRegistrar}).
*/
Provider(
final Options options,
final Evaluator evaluator,
final Boolean spanEnrichmentEnabledOverride,
final TraceInterceptorRegistrar registrar) {
this.options = options;
this.evaluator = evaluator;
FlagEvalMetrics metrics = null;
Expand All @@ -59,6 +115,56 @@ public Provider(final Options options) {
}
this.flagEvalMetrics = metrics;
this.flagEvalHook = hook;

// Span enrichment is wired ONLY when the gate is on. When off, no hook/state is constructed and
// there is no idle per-evaluation or per-span overhead.
final boolean spanEnrichmentEnabled =
spanEnrichmentEnabledOverride != null
? spanEnrichmentEnabledOverride
: isSpanEnrichmentEnabled();
SpanEnrichmentHook seHook = null;
SpanEnrichmentStates seStates = null;
if (spanEnrichmentEnabled) {
try {
// Per-provider state store, shared with this provider's capture hook. The single,
// process-wide interceptor is registered once (reconfiguration-safe) and rebound to this
// provider's store. A later gate-on provider rebinds it to its own store; this provider's
// shutdown only unbinds if it is still the active provider, so reconfiguration never
// permanently disables enrichment and providers never clobber each other's live state.
seStates = new SpanEnrichmentStates();
SpanEnrichmentInterceptor.ensureRegistered(registrar);
SpanEnrichmentInterceptor.INSTANCE.bind(seStates);
seHook = new SpanEnrichmentHook(seStates);
} catch (LinkageError | Exception e) {
// Tracer classes absent (e.g. API-only classpath): degrade to no span enrichment.
log.warn("Span enrichment unavailable — tracer classes not on classpath", e);
seHook = null;
seStates = null;
}
}
this.spanEnrichmentHook = seHook;
this.spanEnrichmentStates = seStates;

// Precompute the immutable hook list once so getProviderHooks() (called on every evaluation)
// allocates nothing, including when the gate is off.
final List<Hook> hooks = new ArrayList<>(2);
if (flagEvalHook != null) {
hooks.add(flagEvalHook);
}
if (seHook != null) {
hooks.add(seHook);
}
this.providerHooks =
hooks.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(hooks);
}

private static boolean isSpanEnrichmentEnabled() {
try {
final String value = ConfigHelper.env(SPAN_ENRICHMENT_ENABLED_ENV);
return "true".equalsIgnoreCase(value) || "1".equals(value);
} catch (final Throwable t) {
return false; // never let config reading break provider construction
}
}

@Override
Expand Down Expand Up @@ -168,22 +274,36 @@ private Evaluator buildEvaluator() throws Exception {

@Override
public List<Hook> getProviderHooks() {
if (flagEvalHook == null) {
return Collections.emptyList();
}
return Collections.singletonList(flagEvalHook);
return providerHooks;
}

@Override
public void shutdown() {
if (flagEvalMetrics != null) {
flagEvalMetrics.shutdown();
}
// Provider-close cleanup for span enrichment: the tracer has no interceptor-removal API, so we
// unbind this provider's store from the process-wide interceptor (which clears the store and
// makes the interceptor inert until a new provider rebinds it). The unbind is a no-op if a
// newer provider has already rebound the interceptor, so we never wipe another provider's
// in-flight state.
if (spanEnrichmentStates != null) {
SpanEnrichmentInterceptor.INSTANCE.unbind(spanEnrichmentStates);
}
if (evaluator != null) {
evaluator.shutdown();
}
}

// Visible for tests: expose whether span enrichment is wired (gate-on) without leaking the impls.
SpanEnrichmentHook spanEnrichmentHook() {
return spanEnrichmentHook;
}

SpanEnrichmentStates spanEnrichmentStates() {
return spanEnrichmentStates;
}

@Override
public Metadata getMetadata() {
return this;
Expand Down
Loading