-
Notifications
You must be signed in to change notification settings - Fork 331
Add flag evaluation metrics via OTel counter and OpenFeature Hook #11040
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
04ae0fb
c5467fb
1816d30
3d789f0
69c5529
18c0441
da92198
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
| @@ -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(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: we probably want to import |
||
|
|
||
| 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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't it be better to just let the error flow to the |
||
| // 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 | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a transitive dependency of our provider, I think it might be better to skip it (otherwise customers will need to ensure compatibility).