-
Notifications
You must be signed in to change notification settings - Fork 77
feat: add user facing impact metrics #342
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
Conversation
| private Variant resolveVariant( | ||
| String toggleName, UnleashContext enhancedContext, Variant defaultValue) { | ||
| Optional<FlatResponse<VariantDef>> response = | ||
| Optional.ofNullable(this.featureRepository.getVariant(toggleName, enhancedContext)); | ||
| Optional<VariantDef> variantDef = response.map(r -> r.value); | ||
| return YggdrasilAdapters.adapt(variantDef, defaultValue); | ||
| } |
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.
We need private resolve variant method that does not dispatch impression events.
| @@ -0,0 +1,4 @@ | |||
| package io.getunleash.impactmetrics; | |||
|
|
|||
| public interface ImpactMetricRegistryAndDataSource | |||
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.
We needed type definition that includes ImpactMetricRegistry and ImpactMetricsDataSource, since in Java we have config that accepts this. This is new to Java, not in Node.
|
|
||
| public void defineCounter(String name, String help) { | ||
| if (name == null || name.isEmpty() || help == null || help.isEmpty()) { | ||
| LOGGER.warn("Counter name or help cannot be empty: {}, {}", name, help); |
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.
We do not have similar event system, like we have in node. We have more about SDK lifecycle, FeatureEvaluated or UnleashReady. In Java we just use logger.
| import org.junit.jupiter.api.Test; | ||
| import org.mockito.ArgumentCaptor; | ||
|
|
||
| public class MetricsAPITest { |
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.
These tests are 1 to 1 taken from metric-client test in Node. The naming in Node is poor, because the metric-client is actually deprecated, so I kept it as MetricsAPI test as this is more correct.
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.
Pull request overview
This PR adds user-facing impact metrics functionality to the Unleash SDK, enabling users to define and track counters, gauges, and histograms associated with feature flags.
- Introduces a new
MetricsAPIclass that provides methods to define and update metrics (counters, gauges, histograms) - Adds supporting classes for metric context (
StaticContext,MetricFlagContext,VariantResolver) - Refactors the impact metrics type hierarchy by introducing
ImpactMetricRegistryAndDataSourceinterface
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/java/io/getunleash/impactmetrics/MetricsAPI.java | New API class for defining and updating user-facing metrics with feature flag context |
| src/main/java/io/getunleash/impactmetrics/StaticContext.java | New value object holding static application context (app name, environment) |
| src/main/java/io/getunleash/impactmetrics/MetricFlagContext.java | New class for passing feature flag context to metric operations |
| src/main/java/io/getunleash/impactmetrics/VariantResolver.java | New interface for resolving variant information for feature flags |
| src/main/java/io/getunleash/impactmetrics/ImpactMetricRegistryAndDataSource.java | New composite interface combining ImpactMetricRegistry and ImpactMetricsDataSource |
| src/main/java/io/getunleash/impactmetrics/ImpactMetricRegistry.java | Extended with getter methods for retrieving registered metrics by name |
| src/main/java/io/getunleash/impactmetrics/InMemoryMetricRegistry.java | Updated to implement new composite interface and getter methods |
| src/main/java/io/getunleash/DefaultUnleash.java | Integrates MetricsAPI and refactors variant resolution logic |
| src/main/java/io/getunleash/util/UnleashConfig.java | Updated type references from ImpactMetricsDataSource to ImpactMetricRegistryAndDataSource |
| src/main/java/io/getunleash/metric/UnleashMetricServiceImpl.java | Updated type references from ImpactMetricsDataSource to ImpactMetricRegistryAndDataSource |
| src/test/java/io/getunleash/impactmetrics/MetricsAPITest.java | Comprehensive test suite for MetricsAPI functionality |
| src/test/java/io/getunleash/metric/UnleashMetricServiceImplTest.java | Updated mock types to use new ImpactMetricRegistryAndDataSource interface |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Optional<FlatResponse<VariantDef>> response = | ||
| Optional.ofNullable(this.featureRepository.getVariant(toggleName, enhancedContext)); |
Copilot
AI
Dec 19, 2025
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.
The variant is being resolved twice for the same toggle and context. Line 155 calls resolveVariant() which internally calls featureRepository.getVariant(), and then line 158 calls it again. The second call is only used to check for impression data. This duplicate call could be avoided by modifying resolveVariant() to return both the variant and the response object, or by restructuring the code to reuse the response from the first call.
| public final MetricsAPI impactMetrics; | ||
|
|
Copilot
AI
Dec 19, 2025
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.
The impactMetrics field is declared as public, which exposes internal implementation details and breaks encapsulation. Consider making this field private and providing a public getter method instead, or exposing it through the Unleash interface if it's intended to be part of the public API.
| public final MetricsAPI impactMetrics; | |
| private final MetricsAPI impactMetrics; | |
| public MetricsAPI getImpactMetrics() { | |
| return impactMetrics; | |
| } |
Pull Request Test Coverage Report for Build 20457737290Warning: This coverage report may be inaccurate.This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.
Details
💛 - Coveralls |
| import io.getunleash.variant.Variant; | ||
|
|
||
| public interface VariantResolver { | ||
| Variant forceGetVariant(String flagName, UnleashContext context); |
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.
getVariantForImpactMetrics
| return variant; | ||
| } | ||
|
|
||
| private Variant getVariantForMetrics(String toggleName, UnleashContext context) { |
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.
getVariantForImpactMetrics
src/main/java/io/getunleash/impactmetrics/ImpactMetricRegistryAndDataSource.java
Show resolved
Hide resolved
| @Test | ||
| public void should_not_include_impact_metrics_field_when_empty() throws YggdrasilError { | ||
| ImpactMetricsDataSource registry = mock(ImpactMetricsDataSource.class); | ||
| ImpactMetricRegistryAndDataSource registry = mock(ImpactMetricRegistryAndDataSource.class); |
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.
Check if we can use these together. Registry might be always datasource.
kwasniew
left a comment
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.
Pair reviewed
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.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 10 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| default void shutdown() {} | ||
|
|
||
| MoreOperations more(); | ||
|
|
Copilot
AI
Dec 23, 2025
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.
The new public method getImpactMetrics() lacks documentation. This is a user-facing API method that should include JavaDoc explaining its purpose, what MetricsAPI provides, and how it should be used. This is especially important as this is a new feature being introduced.
| /** | |
| * Returns the impact metrics API associated with this {@link Unleash} instance. | |
| * <p> | |
| * The {@link MetricsAPI} provides access to functionality for working with impact metrics | |
| * related to feature toggle evaluations, such as recording or retrieving metrics data | |
| * produced by this client. | |
| * </p> | |
| * <p> | |
| * Typical usage is to obtain a reference from an {@code Unleash} instance and then use the | |
| * returned {@link MetricsAPI} to integrate impact metrics with your own monitoring, | |
| * reporting, or analysis tooling. | |
| * </p> | |
| * | |
| * @return the {@link MetricsAPI} for interacting with impact metrics. | |
| */ |
| List<String> labelNames = List.of("featureName", "appName", "environment"); | ||
| metricRegistry.gauge(new MetricOptions(name, help, labelNames)); |
Copilot
AI
Dec 23, 2025
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.
The label names definition uses "featureName" as a single label, but the actual labels map uses individual feature flag names as keys (e.g., "featureX", "featureY" from tests). This creates a mismatch between the declared label schema and the actual labels being used. The label names should either match the actual feature flag names being used, or the implementation should use a consistent "featureName" label key with the flag name as the value.
| @Override | ||
| public MetricsAPI getImpactMetrics() { | ||
| return impactMetrics; | ||
| } |
Copilot
AI
Dec 23, 2025
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.
The new getImpactMetrics() method lacks test coverage. Since comprehensive tests exist for other methods in this file (e.g., more() method is tested), the new user-facing API should also have tests to verify it returns a properly initialized MetricsAPI instance.
| public class StaticContext { | ||
| private final String appName; | ||
| private final String environment; | ||
|
|
||
| public StaticContext(String appName, String environment) { | ||
| this.appName = appName; | ||
| this.environment = environment; |
Copilot
AI
Dec 23, 2025
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.
The constructor does not validate that appName and environment are non-null. Since these values are used in MetricsAPI to populate labels and are retrieved via getter methods, null values could cause NullPointerException when used. Consider adding null checks or using @nullable annotations if null values are intentionally allowed.
| public class StaticContext { | |
| private final String appName; | |
| private final String environment; | |
| public StaticContext(String appName, String environment) { | |
| this.appName = appName; | |
| this.environment = environment; | |
| import java.util.Objects; | |
| public class StaticContext { | |
| private final String appName; | |
| private final String environment; | |
| public StaticContext(String appName, String environment) { | |
| this.appName = Objects.requireNonNull(appName, "appName must not be null"); | |
| this.environment = Objects.requireNonNull(environment, "environment must not be null"); |
| public void defineCounter(String name, String help) { | ||
| if (name == null || name.isEmpty() || help == null || help.isEmpty()) { | ||
| LOGGER.warn("Counter name or help cannot be empty: {}, {}", name, help); | ||
| return; | ||
| } | ||
| List<String> labelNames = List.of("featureName", "appName", "environment"); | ||
| metricRegistry.counter(new MetricOptions(name, help, labelNames)); | ||
| } | ||
|
|
||
| public void defineGauge(String name, String help) { | ||
| if (name == null || name.isEmpty() || help == null || help.isEmpty()) { | ||
| LOGGER.warn("Gauge name or help cannot be empty: {}, {}", name, help); | ||
| return; | ||
| } | ||
| List<String> labelNames = List.of("featureName", "appName", "environment"); | ||
| metricRegistry.gauge(new MetricOptions(name, help, labelNames)); | ||
| } | ||
|
|
||
| public void defineHistogram(String name, String help, @Nullable List<Double> buckets) { | ||
| if (name == null || name.isEmpty() || help == null || help.isEmpty()) { | ||
| LOGGER.warn("Histogram name or help cannot be empty: {}, {}", name, help); | ||
| return; | ||
| } | ||
| List<String> labelNames = List.of("featureName", "appName", "environment"); | ||
| List<Double> bucketList = buckets != null ? buckets : new ArrayList<>(); | ||
| metricRegistry.histogram(new BucketMetricOptions(name, help, labelNames, bucketList)); | ||
| } | ||
|
|
||
| private Map<String, String> getFlagLabels(@Nullable MetricFlagContext flagContext) { | ||
| Map<String, String> flagLabels = new HashMap<>(); | ||
| if (flagContext != null) { | ||
| for (String flag : flagContext.getFlagNames()) { | ||
| Variant variant = variantResolver.forceGetVariant(flag, flagContext.getContext()); | ||
|
|
||
| if (variant.isEnabled()) { | ||
| flagLabels.put(flag, variant.getName()); | ||
| } else if (variant.isFeatureEnabled()) { | ||
| flagLabels.put(flag, "enabled"); | ||
| } else { | ||
| flagLabels.put(flag, "disabled"); | ||
| } | ||
| } | ||
| } | ||
| return flagLabels; | ||
| } | ||
|
|
||
| public void incrementCounter( | ||
| String name, @Nullable Long value, @Nullable MetricFlagContext flagContext) { | ||
| Counter counter = metricRegistry.getCounter(name); | ||
| if (counter == null) { | ||
| LOGGER.warn("Counter {} not defined, this counter will not be incremented.", name); | ||
| return; | ||
| } | ||
|
|
||
| Map<String, String> flagLabels = getFlagLabels(flagContext); | ||
| Map<String, String> labels = new HashMap<>(flagLabels); | ||
| labels.put("appName", staticContext.getAppName()); | ||
| labels.put("environment", staticContext.getEnvironment()); | ||
|
|
||
| counter.inc(value != null ? value : 1L, labels); | ||
| } | ||
|
|
||
| public void updateGauge(String name, long value, @Nullable MetricFlagContext flagContext) { | ||
| Gauge gauge = metricRegistry.getGauge(name); | ||
| if (gauge == null) { | ||
| LOGGER.warn("Gauge {} not defined, this gauge will not be updated.", name); | ||
| return; | ||
| } | ||
|
|
||
| Map<String, String> flagLabels = getFlagLabels(flagContext); | ||
| Map<String, String> labels = new HashMap<>(flagLabels); | ||
| labels.put("appName", staticContext.getAppName()); | ||
| labels.put("environment", staticContext.getEnvironment()); | ||
|
|
||
| gauge.set(value, labels); | ||
| } | ||
|
|
||
| public void observeHistogram( | ||
| String name, double value, @Nullable MetricFlagContext flagContext) { | ||
| Histogram histogram = metricRegistry.getHistogram(name); | ||
| if (histogram == null) { | ||
| LOGGER.warn("Histogram {} not defined, this histogram will not be updated.", name); | ||
| return; | ||
| } | ||
|
|
||
| Map<String, String> flagLabels = getFlagLabels(flagContext); | ||
| Map<String, String> labels = new HashMap<>(flagLabels); | ||
| labels.put("appName", staticContext.getAppName()); | ||
| labels.put("environment", staticContext.getEnvironment()); | ||
|
|
||
| histogram.observe(value, labels); | ||
| } |
Copilot
AI
Dec 23, 2025
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.
The public methods defineCounter, defineGauge, defineHistogram, incrementCounter, updateGauge, and observeHistogram lack JavaDoc documentation. These are user-facing API methods that should document their purpose, parameters, behavior when metrics are not defined, and any side effects or warnings.
| List<String> labelNames = List.of("featureName", "appName", "environment"); | ||
| metricRegistry.counter(new MetricOptions(name, help, labelNames)); |
Copilot
AI
Dec 23, 2025
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.
The label names definition uses "featureName" as a single label, but the actual labels map uses individual feature flag names as keys (e.g., "featureX", "featureY" from tests). This creates a mismatch between the declared label schema and the actual labels being used. The label names should either match the actual feature flag names being used, or the implementation should use a consistent "featureName" label key with the flag name as the value.
| List<String> labelNames = List.of("featureName", "appName", "environment"); | ||
| List<Double> bucketList = buckets != null ? buckets : new ArrayList<>(); |
Copilot
AI
Dec 23, 2025
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.
The label names definition uses "featureName" as a single label, but the actual labels map uses individual feature flag names as keys (e.g., "featureX", "featureY" from tests). This creates a mismatch between the declared label schema and the actual labels being used. The label names should either match the actual feature flag names being used, or the implementation should use a consistent "featureName" label key with the flag name as the value.
| @Override | ||
| public MetricsAPI getImpactMetrics() { | ||
| return impactMetrics; | ||
| } |
Copilot
AI
Dec 23, 2025
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.
The new getImpactMetrics() method lacks test coverage. Since comprehensive tests exist for other methods in this file (e.g., more() method is tested), the new user-facing API should also have tests to verify it returns a properly initialized MetricsAPI instance with the correct configuration.
| Variant variant = variantResolver.forceGetVariant(flag, flagContext.getContext()); | ||
|
|
||
| if (variant.isEnabled()) { | ||
| flagLabels.put(flag, variant.getName()); | ||
| } else if (variant.isFeatureEnabled()) { | ||
| flagLabels.put(flag, "enabled"); | ||
| } else { | ||
| flagLabels.put(flag, "disabled"); | ||
| } |
Copilot
AI
Dec 23, 2025
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.
The variantResolver.forceGetVariant() call does not check if the returned Variant is null before calling methods on it. While the current implementations seem to always return a non-null Variant, the interface contract doesn't explicitly guarantee this. Consider adding a null check or annotating the VariantResolver interface method with @nonnull to make the contract explicit.
|
|
||
| public class MetricFlagContext { | ||
| private final List<String> flagNames; | ||
| private final UnleashContext context; | ||
|
|
||
| public MetricFlagContext(List<String> flagNames, UnleashContext context) { | ||
| this.flagNames = flagNames; | ||
| this.context = context; |
Copilot
AI
Dec 23, 2025
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.
The constructor does not validate that flagNames and context are non-null. Since both fields are accessed without null checks in MetricsAPI.getFlagLabels(), null values will cause NullPointerException. Consider adding validation or using @nullable annotations if null values are intentionally allowed.
| public class MetricFlagContext { | |
| private final List<String> flagNames; | |
| private final UnleashContext context; | |
| public MetricFlagContext(List<String> flagNames, UnleashContext context) { | |
| this.flagNames = flagNames; | |
| this.context = context; | |
| import java.util.Objects; | |
| public class MetricFlagContext { | |
| private final List<String> flagNames; | |
| private final UnleashContext context; | |
| public MetricFlagContext(List<String> flagNames, UnleashContext context) { | |
| this.flagNames = Objects.requireNonNull(flagNames, "flagNames must not be null"); | |
| this.context = Objects.requireNonNull(context, "context must not be null"); |
Add user facing impact metrics methods.