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 @@ -44,8 +44,14 @@ dependencies {
api("dev.openfeature:sdk:1.20.1")

compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap"))
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,126 @@
package datadog.trace.api.openfeature;

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;
private volatile SdkMeterProvider meterProvider;

FlagEvalMetrics() {
try {
String endpoint = System.getenv(ENDPOINT_ENV);
if (endpoint == null || endpoint.isEmpty()) {
String base = System.getenv(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();

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

Meter meter = meterProvider.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;
SdkMeterProvider 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,8 @@ public Provider(final Options options) {
Provider(final Options options, final Evaluator evaluator) {
this.options = options;
this.evaluator = evaluator;
this.flagEvalMetrics = new FlagEvalMetrics();
this.flagEvalHook = new FlagEvalHook(flagEvalMetrics);
}

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

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

@Override
public void shutdown() {
flagEvalMetrics.shutdown();
if (evaluator != null) {
evaluator.shutdown();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package datadog.trace.api.openfeature;

import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;

import dev.openfeature.sdk.ErrorCode;
import dev.openfeature.sdk.FlagEvaluationDetails;
import dev.openfeature.sdk.ImmutableMetadata;
import dev.openfeature.sdk.Reason;
import java.util.Collections;
import org.junit.jupiter.api.Test;

class FlagEvalHookTest {

@Test
void finallyAfterRecordsBasicEvaluation() {
FlagEvalMetrics metrics = mock(FlagEvalMetrics.class);
FlagEvalHook hook = new FlagEvalHook(metrics);

FlagEvaluationDetails<Object> details =
FlagEvaluationDetails.<Object>builder()
.flagKey("my-flag")
.value("on-value")
.variant("on")
.reason(Reason.TARGETING_MATCH.name())
.flagMetadata(
ImmutableMetadata.builder().addString("allocationKey", "default-alloc").build())
.build();

hook.finallyAfter(null, details, Collections.emptyMap());

verify(metrics)
.record(
eq("my-flag"),
eq("on"),
eq(Reason.TARGETING_MATCH.name()),
isNull(),
eq("default-alloc"));
}

@Test
void finallyAfterRecordsErrorEvaluation() {
FlagEvalMetrics metrics = mock(FlagEvalMetrics.class);
FlagEvalHook hook = new FlagEvalHook(metrics);

FlagEvaluationDetails<Object> details =
FlagEvaluationDetails.<Object>builder()
.flagKey("missing-flag")
.value("default")
.reason(Reason.ERROR.name())
.errorCode(ErrorCode.FLAG_NOT_FOUND)
.build();

hook.finallyAfter(null, details, Collections.emptyMap());

verify(metrics)
.record(
eq("missing-flag"),
isNull(),
eq(Reason.ERROR.name()),
eq(ErrorCode.FLAG_NOT_FOUND),
isNull());
}

@Test
void finallyAfterHandlesNullFlagMetadata() {
FlagEvalMetrics metrics = mock(FlagEvalMetrics.class);
FlagEvalHook hook = new FlagEvalHook(metrics);

FlagEvaluationDetails<Object> details =
FlagEvaluationDetails.<Object>builder()
.flagKey("my-flag")
.value(true)
.variant("on")
.reason(Reason.TARGETING_MATCH.name())
.build();

hook.finallyAfter(null, details, Collections.emptyMap());

verify(metrics)
.record(eq("my-flag"), eq("on"), eq(Reason.TARGETING_MATCH.name()), isNull(), isNull());
}

@Test
void finallyAfterHandlesNullVariantAndReason() {
FlagEvalMetrics metrics = mock(FlagEvalMetrics.class);
FlagEvalHook hook = new FlagEvalHook(metrics);

FlagEvaluationDetails<Object> details =
FlagEvaluationDetails.<Object>builder().flagKey("my-flag").value("default").build();

hook.finallyAfter(null, details, Collections.emptyMap());

verify(metrics).record(eq("my-flag"), isNull(), isNull(), isNull(), isNull());
}

@Test
void finallyAfterNeverThrows() {
FlagEvalMetrics metrics = mock(FlagEvalMetrics.class);
FlagEvalHook hook = new FlagEvalHook(metrics);

// Should not throw even with completely null inputs
hook.finallyAfter(null, null, null);

verifyNoInteractions(metrics);
}

@Test
void finallyAfterIsNoOpWhenMetricsIsNull() {
FlagEvalHook hook = new FlagEvalHook(null);

FlagEvaluationDetails<Object> details =
FlagEvaluationDetails.<Object>builder()
.flagKey("my-flag")
.value(true)
.variant("on")
.reason(Reason.TARGETING_MATCH.name())
.build();

// Should not throw
hook.finallyAfter(null, details, Collections.emptyMap());
}
}
Loading
Loading