Skip to content
Open
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
82 changes: 82 additions & 0 deletions products/feature-flagging/feature-flagging-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# dd-openfeature

Datadog OpenFeature Provider for Java. Implements the [OpenFeature](https://openfeature.dev/) `FeatureProvider` interface for Datadog's Feature Flags and Experimentation (FFE) product.

Published as `com.datadoghq:dd-openfeature` on Maven Central.

## Setup

```xml
<dependency>
<groupId>com.datadoghq</groupId>
<artifactId>dd-openfeature</artifactId>
<version>${dd-openfeature.version}</version>
</dependency>
<dependency>
<groupId>dev.openfeature</groupId>
<artifactId>sdk</artifactId>
<version>1.20.1</version>
</dependency>
```

### Evaluation metrics (optional)

To enable evaluation metrics (`feature_flag.evaluations` counter), add the OpenTelemetry SDK dependencies:

```xml
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk-metrics</artifactId>
<version>1.47.0</version>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
<version>1.47.0</version>
</dependency>
```

Any OpenTelemetry API 1.x version is compatible. If these dependencies are absent, the provider operates normally without metrics.

## Usage

```java
import datadog.trace.api.openfeature.Provider;
import dev.openfeature.sdk.OpenFeatureAPI;
import dev.openfeature.sdk.Client;

OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.setProviderAndWait(new Provider());
Client client = api.getClient();

boolean enabled = client.getBooleanValue("my-feature", false,
new MutableContext("user-123"));
```

## Evaluation metrics

When the OTel SDK dependencies are on the classpath, the provider records a `feature_flag.evaluations` counter via OTLP HTTP/protobuf. Metrics are exported every 10 seconds to the Datadog Agent's OTLP receiver.

### Configuration

| Environment variable | Description | Default |
|---|---|---|
| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Signal-specific OTLP endpoint (used as-is) ||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Generic OTLP endpoint (`/v1/metrics` appended) ||
| (none set) | Default endpoint | `http://localhost:4318/v1/metrics` |

### Metric attributes

| Attribute | Description |
|---|---|
| `feature_flag.key` | Flag key |
| `feature_flag.result.variant` | Resolved variant key |
| `feature_flag.result.reason` | Evaluation reason (lowercased) |
| `error.type` | Error code (lowercased, only on error) |
| `feature_flag.result.allocation_key` | Allocation key (when present) |

## Requirements

- Java 11+
- Datadog Agent with Remote Configuration enabled
- `DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true`
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,15 @@ dependencies {
api("dev.openfeature:sdk:1.20.1")

compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap"))
compileOnly(project(":utils:config-utils"))
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("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")
testImplementation(libs.bundles.junit5)
testImplementation(libs.bundles.mockito)
testImplementation(libs.moshi)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package datadog.trace.api.openfeature;

import dev.openfeature.sdk.FlagEvaluationDetails;
import dev.openfeature.sdk.Hook;
import dev.openfeature.sdk.HookContext;
import dev.openfeature.sdk.ImmutableMetadata;
import java.util.Map;

class FlagEvalHook implements Hook<Object> {

private final FlagEvalMetrics metrics;

FlagEvalHook(FlagEvalMetrics metrics) {
this.metrics = metrics;
}

@Override
public void finallyAfter(
HookContext<Object> ctx, FlagEvaluationDetails<Object> details, Map<String, Object> hints) {
if (metrics == null || details == null) {
return;
}
try {
String flagKey = details.getFlagKey();
String variant = details.getVariant();
String reason = details.getReason();
dev.openfeature.sdk.ErrorCode errorCode = details.getErrorCode();

String allocationKey = null;
ImmutableMetadata metadata = details.getFlagMetadata();
if (metadata != null) {
allocationKey = metadata.getString("allocationKey");
}

metrics.record(flagKey, variant, reason, errorCode, allocationKey);
} catch (Exception e) {
// Never let metrics recording break flag evaluation
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package datadog.trace.api.openfeature;

import datadog.trace.config.inversion.ConfigHelper;
import dev.openfeature.sdk.ErrorCode;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.common.AttributesBuilder;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.metrics.Meter;
import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter;
import io.opentelemetry.sdk.metrics.SdkMeterProvider;
import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader;
import java.io.Closeable;
import java.time.Duration;

class FlagEvalMetrics implements Closeable {

private static final String METER_NAME = "ddtrace.openfeature";
private static final String METRIC_NAME = "feature_flag.evaluations";
private static final String METRIC_UNIT = "{evaluation}";
private static final String METRIC_DESC = "Number of feature flag evaluations";
private static final Duration EXPORT_INTERVAL = Duration.ofSeconds(10);

private static final String DEFAULT_ENDPOINT = "http://localhost:4318/v1/metrics";
// Signal-specific env var (used as-is, must include /v1/metrics path)
private static final String ENDPOINT_ENV = "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT";
// Generic env var fallback (base URL, /v1/metrics is appended)
private static final String ENDPOINT_GENERIC_ENV = "OTEL_EXPORTER_OTLP_ENDPOINT";

private static final AttributeKey<String> ATTR_FLAG_KEY =
AttributeKey.stringKey("feature_flag.key");
private static final AttributeKey<String> ATTR_VARIANT =
AttributeKey.stringKey("feature_flag.result.variant");
private static final AttributeKey<String> ATTR_REASON =
AttributeKey.stringKey("feature_flag.result.reason");
private static final AttributeKey<String> ATTR_ERROR_TYPE = AttributeKey.stringKey("error.type");
private static final AttributeKey<String> ATTR_ALLOCATION_KEY =
AttributeKey.stringKey("feature_flag.result.allocation_key");

private volatile LongCounter counter;
// Typed as Closeable to avoid loading SdkMeterProvider at class-load time
// when the OTel SDK is absent from the classpath
private volatile java.io.Closeable meterProvider;

FlagEvalMetrics() {
try {
String endpoint = ConfigHelper.env(ENDPOINT_ENV);
if (endpoint == null || endpoint.isEmpty()) {
String base = ConfigHelper.env(ENDPOINT_GENERIC_ENV);
if (base != null && !base.isEmpty()) {
endpoint = base.endsWith("/") ? base + "v1/metrics" : base + "/v1/metrics";
} else {
endpoint = DEFAULT_ENDPOINT;
}
}

OtlpHttpMetricExporter exporter =
OtlpHttpMetricExporter.builder().setEndpoint(endpoint).build();

PeriodicMetricReader reader =
PeriodicMetricReader.builder(exporter).setInterval(EXPORT_INTERVAL).build();

SdkMeterProvider sdkMeterProvider =
SdkMeterProvider.builder().registerMetricReader(reader).build();
meterProvider = sdkMeterProvider;

Meter meter = sdkMeterProvider.meterBuilder(METER_NAME).build();
counter =
meter
.counterBuilder(METRIC_NAME)
.setUnit(METRIC_UNIT)
.setDescription(METRIC_DESC)
.build();
} catch (NoClassDefFoundError | Exception e) {
// OTel SDK not on classpath or initialization failed — counter stays null (no-op)
counter = null;
meterProvider = null;
}
}

/** Package-private constructor for testing with a mock counter. */
FlagEvalMetrics(LongCounter counter) {
this.counter = counter;
this.meterProvider = null;
}

void record(
String flagKey, String variant, String reason, ErrorCode errorCode, String allocationKey) {
LongCounter c = counter;
if (c == null) {
return;
}
try {
AttributesBuilder builder =
Attributes.builder()
.put(ATTR_FLAG_KEY, flagKey)
.put(ATTR_VARIANT, variant != null ? variant : "")
.put(ATTR_REASON, reason != null ? reason.toLowerCase() : "unknown");

if (errorCode != null) {
builder.put(ATTR_ERROR_TYPE, errorCode.name().toLowerCase());
}

if (allocationKey != null && !allocationKey.isEmpty()) {
builder.put(ATTR_ALLOCATION_KEY, allocationKey);
}

c.add(1, builder.build());
} catch (Exception e) {
// Never let metrics recording break flag evaluation
}
}

@Override
public void close() {
shutdown();
}

void shutdown() {
counter = null;
java.io.Closeable mp = meterProvider;
if (mp != null) {
meterProvider = null;
try {
mp.close();
} catch (Exception e) {
// Ignore shutdown errors
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import de.thetaphi.forbiddenapis.SuppressForbidden;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.EventProvider;
import dev.openfeature.sdk.Hook;
import dev.openfeature.sdk.Metadata;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.ProviderEvent;
Expand All @@ -14,6 +15,8 @@
import dev.openfeature.sdk.exceptions.OpenFeatureError;
import dev.openfeature.sdk.exceptions.ProviderNotReadyError;
import java.lang.reflect.Constructor;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

Expand All @@ -25,6 +28,8 @@ public class Provider extends EventProvider implements Metadata {
private volatile Evaluator evaluator;
private final Options options;
private final AtomicBoolean initialized = new AtomicBoolean(false);
private final FlagEvalMetrics flagEvalMetrics;
private final FlagEvalHook flagEvalHook;

public Provider() {
this(DEFAULT_OPTIONS, null);
Expand All @@ -37,6 +42,16 @@ public Provider(final Options options) {
Provider(final Options options, final Evaluator evaluator) {
this.options = options;
this.evaluator = evaluator;
FlagEvalMetrics metrics = null;
FlagEvalHook hook = null;
try {
metrics = new FlagEvalMetrics();
hook = new FlagEvalHook(metrics);
} catch (NoClassDefFoundError | Exception e) {
// OTel classes not on classpath — metrics disabled
}
this.flagEvalMetrics = metrics;
this.flagEvalHook = hook;
}

@Override
Expand Down Expand Up @@ -77,8 +92,19 @@ private Evaluator buildEvaluator() throws Exception {
return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange);
}

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

@Override
public void shutdown() {
if (flagEvalMetrics != null) {
flagEvalMetrics.shutdown();
}
if (evaluator != null) {
evaluator.shutdown();
}
Expand Down
Loading
Loading