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
Expand Up @@ -38,6 +38,7 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;

class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {

Expand Down Expand Up @@ -147,6 +148,8 @@ public <T> ProviderEvaluation<T> evaluate(
.value(defaultValue)
.reason(Reason.DEFAULT.name())
.build();
} catch (final PatternSyntaxException e) {
return error(defaultValue, ErrorCode.PARSE_ERROR, e);
} catch (final NumberFormatException e) {
return error(defaultValue, ErrorCode.TYPE_MISMATCH, e);
} catch (final Exception e) {
Expand Down Expand Up @@ -250,12 +253,10 @@ private static boolean evaluateCondition(
}

private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) {
try {
final Pattern pattern = Pattern.compile(String.valueOf(conditionValue));
return pattern.matcher(String.valueOf(attributeValue)).find();
} catch (Exception e) {
return false;
}
// PatternSyntaxException is intentionally not caught here so it propagates to evaluate(),
// which maps it to ErrorCode.PARSE_ERROR.
final Pattern pattern = Pattern.compile(String.valueOf(conditionValue));
return pattern.matcher(String.valueOf(attributeValue)).find();
}

private static boolean isOneOf(final Object attributeValue, final Object conditionValue) {
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,130 @@
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;
// 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 = 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();

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,14 @@ public Provider(final Options options) {
Provider(final Options options, final Evaluator evaluator) {
this.options = options;
this.evaluator = evaluator;
FlagEvalMetrics metrics = null;
try {
metrics = new FlagEvalMetrics();
} catch (NoClassDefFoundError | Exception e) {
// OTel classes not on classpath — metrics disabled
}
this.flagEvalMetrics = metrics;
this.flagEvalHook = new FlagEvalHook(metrics);
}

@Override
Expand Down Expand Up @@ -77,8 +90,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
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,12 @@ private static List<TestCase<?>> evaluateTestCases() {
.result(
new Result<>("null-handled")
.reason(TARGETING_MATCH.name())
.variant("null-variant")));
.variant("null-variant")),
new TestCase<>("default")
.flag("invalid-regex-flag")
.targetingKey("user-123")
.context("email", "user@example.com")
.result(new Result<>("default").reason(ERROR.name()).errorCode(ErrorCode.PARSE_ERROR)));
}

@MethodSource("evaluateTestCases")
Expand Down Expand Up @@ -551,6 +556,7 @@ private ServerConfiguration createTestConfiguration() {
flags.put("not-one-of-false-flag", createNotOneOfFalseFlag());
flags.put("null-context-values-flag", createNullContextValuesFlag());
flags.put("country-rule-flag", createCountryRuleFlag());
flags.put("invalid-regex-flag", createInvalidRegexFlag());
return new ServerConfiguration(null, null, null, flags);
}

Expand Down Expand Up @@ -1236,6 +1242,22 @@ private Flag createCountryRuleFlag() {
asList(usAllocation, globalAllocation));
}

private Flag createInvalidRegexFlag() {
final Map<String, Variant> variants = new HashMap<>();
variants.put("matched", new Variant("matched", "matched-value"));

// Condition with an intentionally invalid regex pattern (unclosed bracket)
final List<ConditionConfiguration> conditions =
singletonList(new ConditionConfiguration(ConditionOperator.MATCHES, "email", "[invalid"));
final List<Rule> rules = singletonList(new Rule(conditions));
final List<Split> splits = singletonList(new Split(emptyList(), "matched", null));
final Allocation allocation =
new Allocation("invalid-regex-alloc", rules, null, null, splits, false);

return new Flag(
"invalid-regex-flag", true, ValueType.STRING, variants, singletonList(allocation));
}

private static Map<String, Object> mapOf(final Object... props) {
final Map<String, Object> result = new HashMap<>(props.length << 1);
int index = 0;
Expand Down
Loading