Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.datadog.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<Object> {
private static final String PARAMETER = "dd_evaluation_telemetry_opt_out";
private static final String DECISION = "datadog.evaluationTelemetryOptOut";
private final Hook<Object> delegate;

EvaluationTelemetryOptOutHook(final Hook<Object> delegate) {
this.delegate = delegate;
}

@Override
public Optional<EvaluationContext> before(
final HookContext<Object> context, final Map<String, Object> 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<Object> context, final Exception error, final Map<String, Object> 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<Object> context,
final FlagEvaluationDetails<Object> details,
final Map<String, Object> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static java.util.concurrent.TimeUnit.SECONDS;

import com.datadog.openfeature.internal.FeatureFlagsRuntime;
import com.datadog.openfeature.internal.FeatureFlagsRuntime.RemoteConfigurationUnavailableException;
import com.datadog.openfeature.internal.bridge.AgentBridge;
import com.datadog.openfeature.internal.bridge.AgentBridges;
import com.datadog.openfeature.internal.config.Settings;
Expand Down Expand Up @@ -111,7 +112,13 @@ public Provider(final Options options) {
if (spanEnrichmentHook != null) {
hooks.add(spanEnrichmentHook);
}
// Exposure hook: always registered last; no-op when the evaluator is not a Datadog one or is
if (options.isEvaluationTelemetryOptOutParameterEnabled(this.settings)) {
for (int i = 0; i < hooks.size(); i++) {
hooks.set(i, new EvaluationTelemetryOptOutHook(hooks.get(i)));
}
}
// Exposure hook: always registered last, and independent of evaluation telemetry opt-out; no-op
// when the evaluator is not a Datadog one or is
// not initialized.
hooks.add(new ExposureLoggingHook<>(this::datadogEvaluator));
this.providerHooks =
Expand Down Expand Up @@ -144,6 +151,14 @@ public void initialize(final EvaluationContext context) throws Exception {
throw new ProviderNotReadyError(
"Provider timed-out while waiting for initial configuration");
}
} catch (final RemoteConfigurationUnavailableException e) {
// No RC connector means no configuration can arrive in this JVM. Complete registration
// without waiting or switching sources. The evaluator has no runtime/configuration and
// returns caller defaults with PROVIDER_NOT_READY, never successful flag assignments.
// OpenFeature marks the completed registration READY; that does not imply RC is available.
initializationState.set(InitializationState.FALLBACK);
log.warn("{}; returning caller defaults", e.getMessage());
log.debug("Feature Flags Remote Configuration connector unavailable", e);
} catch (final OpenFeatureError e) {
markInitializationError();
throw e;
Expand Down Expand Up @@ -310,14 +325,45 @@ private enum InitializationState {
INITIALIZING,
INITIAL_CONFIG_RECEIVED,
READY,
ERROR
ERROR,
FALLBACK
}

/** The provider options. */
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.
*
* <p>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.
*
* <p>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(final Settings settings) {
if (this.evaluationTelemetryOptOutParameterEnabled != null) {
return this.evaluationTelemetryOptOutParameterEnabled;
}
return settings.isEvaluationTelemetryPerSubjectOptOutEnabled();
}

/**
* Sets how long provider initialization waits for the initial flag configuration.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.datadog.openfeature.internal;

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

/**
* An event pipeline stopping in two phases, so that several pipelines can share one shutdown
* budget: every pipeline is first asked to flush, then each one is awaited until the same deadline.
*/
public interface EventPipeline extends AutoCloseable {
/** The time the pipelines get to flush their pending events when stopping. */
long SHUTDOWN_TIMEOUT_MILLIS = 800;

/** Stops accepting events and asks the worker to flush the pending ones, without waiting. */
void requestShutdown();

/**
* Waits for the worker to flush the pending events and exit.
*
* @param deadlineNanos the {@link System#nanoTime()} value to wait until.
*/
void awaitTermination(long deadlineNanos);

/** Stops the pipeline, waiting at most {@link #SHUTDOWN_TIMEOUT_MILLIS} for the final flush. */
@Override
default void close() {
final long deadline = shutdownDeadline();
requestShutdown();
awaitTermination(deadline);
}

/**
* @return the deadline of a shutdown starting now.
*/
static long shutdownDeadline() {
return System.nanoTime() + MILLISECONDS.toNanos(SHUTDOWN_TIMEOUT_MILLIS);
}

/**
* Waits for a worker thread to exit.
*
* @param worker the worker thread.
* @param deadlineNanos the {@link System#nanoTime()} value to wait until.
*/
static void awaitThread(final Thread worker, final long deadlineNanos) {
final long remaining = deadlineNanos - System.nanoTime();
if (worker == Thread.currentThread() || remaining <= 0) {
return;
}
try {
NANOSECONDS.timedJoin(worker, remaining);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.datadog.openfeature.internal.bridge.AgentBridge;
import com.datadog.openfeature.internal.bridge.ConfigurationSource;
import com.datadog.openfeature.internal.bridge.EventTransport;
import com.datadog.openfeature.internal.bridge.ShutdownSource;
import com.datadog.openfeature.internal.config.Settings;
import com.datadog.openfeature.internal.delivery.CdnConfigurationSource;
import com.datadog.openfeature.internal.delivery.DirectIntakeTransport;
Expand All @@ -20,14 +21,17 @@
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import javax.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* The Feature Flags runtime: configuration delivery and event pipelines. A single runtime is shared
* by all the providers of a class loader, and stopped when the last one releases it.
* by all the providers of a class loader, and stopped when the last one releases it, or when the
* application shuts down: by the Datadog Java agent before the JVM shutdown hooks run when
* attached, or by a JVM shutdown hook otherwise.
*/
public final class FeatureFlagsRuntime {
private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagsRuntime.class);
Expand All @@ -42,6 +46,8 @@ public final class FeatureFlagsRuntime {
@Nullable private final FlagEvaluationPipeline evaluations;
private final List<Consumer<ServerConfiguration>> listeners = new CopyOnWriteArrayList<>();
private volatile ServerConfiguration configuration;
private final AtomicBoolean stopped = new AtomicBoolean();
@Nullable private volatile AutoCloseable shutdownRegistration;

FeatureFlagsRuntime(
final ConfigurationService source,
Expand All @@ -66,6 +72,7 @@ public static FeatureFlagsRuntime acquire(final AgentBridge bridge, final Settin
if (shared == null) {
final FeatureFlagsRuntime created = create(bridge, settings);
created.start();
created.registerShutdown(bridge);
shared = created;
}
references++;
Expand Down Expand Up @@ -97,11 +104,14 @@ static FeatureFlagsRuntime create(final AgentBridge bridge, final Settings setti
final EventTransport transport;
if (remoteConfig) {
// TODO Support Remote Configuration without the Datadog Java agent.
final ConfigurationSource remoteConfiguration = bridge.remoteConfiguration();
final ConfigurationSource remoteConfiguration;
try {
remoteConfiguration = bridge.remoteConfiguration();
} catch (final LinkageError e) {
throw new RemoteConfigurationUnavailableException(e);
}
if (remoteConfiguration == null) {
throw new IllegalStateException(
"Feature Flags Remote Configuration source requires the Datadog Java agent with"
+ " Remote Configuration enabled");
throw new RemoteConfigurationUnavailableException(null);
}
source = new RemoteConfigurationService(remoteConfiguration);
// Remote Configuration never falls back to direct intake.
Expand Down Expand Up @@ -142,6 +152,17 @@ static FeatureFlagsRuntime create(final AgentBridge bridge, final Settings setti
return new FeatureFlagsRuntime(source, exposures, evaluations);
}

/** Signals an unavailable RC connector before any runtime resources have been started. */
public static final class RemoteConfigurationUnavailableException extends IllegalStateException {
private RemoteConfigurationUnavailableException(@Nullable final LinkageError cause) {
super(
"Feature Flags Remote Configuration requires a compatible Datadog Java agent with"
+ " Remote Configuration enabled; attach a compatible agent or configure"
+ " DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless for CDN delivery",
cause);
}
}

private static Map<String, String> eventContext(final Settings settings) {
final Map<String, String> context = new HashMap<>();
putIfNotNull(context, "service", settings.service());
Expand Down Expand Up @@ -172,13 +193,67 @@ void start() {
}
}

/**
* Stops the runtime at application shutdown: through the agent when it supports it, otherwise
* through a JVM shutdown hook.
*
* @param bridge the bridge to the Datadog Java agent.
*/
void registerShutdown(final AgentBridge bridge) {
AutoCloseable registration = null;
try {
final ShutdownSource shutdown = bridge.shutdown();
if (shutdown != null) {
registration = shutdown.subscribe(this::stop);
}
} catch (final RuntimeException | LinkageError e) {
LOGGER.debug("Could not subscribe to the agent shutdown, using a JVM shutdown hook", e);
}
this.shutdownRegistration = registration == null ? addShutdownHook() : registration;
}

@Nullable
private AutoCloseable addShutdownHook() {
final Thread hook = new Thread(this::stop, "dd-openfeature-shutdown");
try {
Runtime.getRuntime().addShutdownHook(hook);
} catch (final IllegalStateException | SecurityException e) {
LOGGER.debug("Could not register the Feature Flags shutdown hook", e);
return null;
}
return () -> {
if (Thread.currentThread() != hook) {
Runtime.getRuntime().removeShutdownHook(hook);
}
};
}

void stop() {
if (!this.stopped.compareAndSet(false, true)) {
return;
}
final AutoCloseable registration = this.shutdownRegistration;
if (registration != null) {
try {
registration.close();
} catch (final Exception e) {
// The JVM is already shutting down, or the agent backend is gone.
}
}
// Signal both pipelines before waiting for either, so they flush in parallel within one budget.
final long deadline = EventPipeline.shutdownDeadline();
if (this.evaluations != null) {
this.evaluations.requestShutdown();
}
if (this.exposures != null) {
this.exposures.requestShutdown();
}
this.source.close();
if (this.evaluations != null) {
this.evaluations.close();
this.evaluations.awaitTermination(deadline);
}
if (this.exposures != null) {
this.exposures.close();
this.exposures.awaitTermination(deadline);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,11 @@ public interface AgentBridge {
* @return the span enricher.
*/
SpanEnricher spanEnricher();

/**
* @return the agent shutdown notifications, sent before the JVM shutdown hooks run, or {@code
* null} if not available, so the SDK must flush from its own JVM shutdown hook.
*/
@Nullable
ShutdownSource shutdown();
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,10 @@ public HealthMetrics healthMetrics() {
public SpanEnricher spanEnricher() {
return SpanEnricher.NOOP;
}

@Nullable
@Override
public ShutdownSource shutdown() {
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.datadog.openfeature.internal.bridge;

/** A source of application shutdown notifications. */
public interface ShutdownSource {
/**
* Subscribes to the application shutdown.
*
* @param listener the listener run at shutdown, typically flushing the pending events.
* @return the handle to close to unsubscribe.
*/
AutoCloseable subscribe(Runnable listener);
}
Loading
Loading